feat(web): redesign UI as an OS-style desktop shell
Replace the sidebar + hash-routed page shell with a desktop metaphor:
draggable app icons, apps opening as floating wmkit windows, a centered
"What should Nomos do?" task launcher, and a bottom taskbar showing all
open windows plus a system tray.
- New app registry ($lib/apps.ts) — adding an app is one entry, nothing
else to touch.
- New desktop shell components (Desktop, WindowLayer, DesktopIcon,
Taskbar, TaskLauncher) under $lib/components/desktop-shell/.
- Icon positions are a persisted, collision-avoiding grid ($lib/stores/icons.ts).
- Window layout persists across reloads (wmkit persist), with
drag-to-maximize, F6 window cycling, and now a right-click desktop menu
(cascade/tile/show desktop/reset icons) plus Cmd/Ctrl+Z undo/redo for
window moves, resizes, and closes.
- Taskbar buttons get a hover-close and self-correct their title once a
new task's real goal is known.
- New task windows (desktop launcher and the Tasks app's "New task"
button) open as a window, not a dialog, and hand off to the real
session window once the backend assigns an id.
- Fixed a real gap along the way: GET /sessions/{id} couldn't tell
"session deleted" from "session has no messages yet" (both returned
200 with an empty list) — cmd/nomos/main.go now checks existence and
404s, so a stale/persisted task window shows "Task not found" instead
of a misleadingly empty, live-looking chat.
- Test coverage for the new pure logic (icon placement/collision
avoidance, app registry id helpers) plus a vitest matchMedia polyfill
needed to import anything touching the theme store.
Deletes the now-superseded sidebar shell, MinimizedWindowsBar, and the
standalone Chat/EntityDetail pages (folded into the window layer).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/dtoro/oikos/internal/safego"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -448,6 +449,21 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
|
||||
w.WriteHeader(204)
|
||||
|
||||
case http.MethodGet:
|
||||
// getMessages alone can't distinguish "session exists but has no
|
||||
// messages yet" from "session id doesn't exist at all" — it's a
|
||||
// plain WHERE session_id=$1 query that returns zero rows either
|
||||
// way. A frontend window opened for a deleted/invalid session
|
||||
// (persisted layout, a stale link) needs to tell those apart, so
|
||||
// check existence explicitly and 404 rather than silently
|
||||
// returning an empty transcript that looks like a fresh task.
|
||||
if _, err := st.getSession(r.Context(), id); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
http.Error(w, "session not found", 404)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
messages, err := st.getMessages(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
|
||||
@@ -1,47 +1,42 @@
|
||||
<script lang="ts">
|
||||
import Chat from './pages/Chat.svelte'
|
||||
import Overview from './pages/Overview.svelte'
|
||||
import KnowledgeBase from './pages/KnowledgeBase.svelte'
|
||||
import Ops from './pages/Ops.svelte'
|
||||
import Signals from './pages/Signals.svelte'
|
||||
import EntityDetail from './pages/EntityDetail.svelte'
|
||||
import Knowledge from './pages/Knowledge.svelte'
|
||||
import Learning from './pages/Learning.svelte'
|
||||
import Config from './pages/Config.svelte'
|
||||
import EntityDesktop from '$lib/components/EntityDesktop.svelte'
|
||||
import MinimizedWindowsBar from '$lib/components/MinimizedWindowsBar.svelte'
|
||||
import { newChat } from '$lib/stores/chat'
|
||||
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
||||
import { currentTask } from '$lib/stores/workspace'
|
||||
import Desktop from '$lib/components/desktop-shell/Desktop.svelte'
|
||||
import { subscribeContext } from '$lib/stores/context'
|
||||
import { openAppWindow, openEntityWindow } from '$lib/stores/windows'
|
||||
import { isConfigured } from '$lib/config'
|
||||
import { truncateMiddle } from '$lib/utils'
|
||||
import { onMount } from 'svelte'
|
||||
import { processPendingCallback, initOIDC } from '$lib/oidc'
|
||||
import * as Sidebar from '$lib/components/ui/sidebar'
|
||||
import * as Sheet from '$lib/components/ui/sheet'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import { VERSION } from '$lib/version'
|
||||
import { Toaster } from '$lib/components/ui/sonner'
|
||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
import PaletteIcon from '@lucide/svelte/icons/palette'
|
||||
import { getTheme, toggleTheme, THEME_LABELS } from '$lib/stores/theme.svelte'
|
||||
|
||||
let page = $state('overview')
|
||||
let routeParam = $state('')
|
||||
let drawerOpen = $state(false)
|
||||
let configured = $state(isConfigured())
|
||||
|
||||
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
|
||||
const openSignals = $derived(openSignalCount($summary))
|
||||
// Old hash routes (#/kb, #/entity/<slug>, ...) from the sidebar-shell era —
|
||||
// translated into opening the equivalent window once, then cleared, so
|
||||
// links/bookmarks from before the desktop redesign keep working without
|
||||
// reintroducing a router.
|
||||
const LEGACY_APP_ROUTES: Record<string, string> = {
|
||||
overview: 'tasks',
|
||||
chat: 'tasks',
|
||||
kb: 'kb',
|
||||
entities: 'kb',
|
||||
graph: 'kb',
|
||||
ops: 'ops',
|
||||
signals: 'signals',
|
||||
knowledge: 'knowledge',
|
||||
learning: 'learning'
|
||||
}
|
||||
|
||||
function resolveLegacyHash() {
|
||||
const path = location.hash.slice(2)
|
||||
if (!path) return
|
||||
const [head, ...rest] = path.split('/')
|
||||
if (head === 'entity' && rest.length) {
|
||||
openEntityWindow(rest.join('/'))
|
||||
} else if (LEGACY_APP_ROUTES[head]) {
|
||||
openAppWindow(LEGACY_APP_ROUTES[head])
|
||||
}
|
||||
history.replaceState(null, '', location.pathname + location.search)
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (await processPendingCallback()) {
|
||||
@@ -49,21 +44,7 @@
|
||||
} else if (!configured) {
|
||||
if (await initOIDC()) configured = true
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const path = location.hash.slice(2) || 'overview'
|
||||
const [head, ...rest] = path.split('/')
|
||||
// Entities + Graph were merged into Knowledge Base — keep old links working.
|
||||
if (head === 'entities' || head === 'graph') {
|
||||
location.hash = '#/kb'
|
||||
return
|
||||
}
|
||||
page = head || 'overview'
|
||||
routeParam = rest.join('/')
|
||||
}
|
||||
sync()
|
||||
window.addEventListener('hashchange', sync)
|
||||
return () => window.removeEventListener('hashchange', sync)
|
||||
resolveLegacyHash()
|
||||
})
|
||||
|
||||
// Context (dashboard summary + approvals poll) and the SSE stream both
|
||||
@@ -72,23 +53,6 @@
|
||||
if (!configured) return
|
||||
return subscribeContext()
|
||||
})
|
||||
|
||||
function navigate(p: string) {
|
||||
location.hash = '#/' + p
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
toggleTheme()
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ id: 'overview', label: 'Tasks', icon: ListTodoIcon },
|
||||
{ id: 'kb', label: 'Knowledge Base', icon: DatabaseIcon },
|
||||
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
|
||||
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
|
||||
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
||||
{ id: 'learning', label: 'Learning', icon: TrendingUpIcon }
|
||||
]
|
||||
</script>
|
||||
|
||||
{#if !configured}
|
||||
@@ -97,161 +61,6 @@
|
||||
onCancel={isConfigured() ? () => (configured = true) : undefined}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
<Toaster />
|
||||
|
||||
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
|
||||
<Sidebar.Root collapsible="icon" variant="inset">
|
||||
<Sidebar.Header>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
class="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
onclick={() => navigate('overview')}
|
||||
tooltipContent={`Oikos ${VERSION}`}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<button {...props}>
|
||||
<svg viewBox="0 0 91 100" class="!size-5 shrink-0" fill="var(--primary)" aria-hidden="true" role="img">
|
||||
<title>Oikos</title>
|
||||
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
<div class="group-data-[collapsible=icon]:hidden px-2.5 pb-1">
|
||||
<span class="text-[11px] text-muted-foreground select-none">{VERSION}</span>
|
||||
</div>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
|
||||
onclick={() => { newChat(); navigate('chat') }}
|
||||
tooltipContent="New task"
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<button {...props}>
|
||||
<PlusIcon />
|
||||
<span>New task</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group>
|
||||
<Sidebar.Menu>
|
||||
{#each navItems as item}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
isActive={page === item.id || (item.id === 'overview' && page === 'chat')}
|
||||
onclick={() => navigate(item.id)}
|
||||
tooltipContent={item.label}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<button {...props}>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
{#if item.badge?.()}
|
||||
<Sidebar.MenuBadge>{item.badge()}</Sidebar.MenuBadge>
|
||||
{/if}
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
</Sidebar.Content>
|
||||
|
||||
<Sidebar.Footer>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => (drawerOpen = true)}
|
||||
title="Chat over the current page without navigating away"
|
||||
>
|
||||
<PanelRightIcon />
|
||||
<span>Chat drawer</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={cycleTheme}
|
||||
title="Cycle theme"
|
||||
>
|
||||
<PaletteIcon />
|
||||
<span>{THEME_LABELS[getTheme()]}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start gap-2"
|
||||
onclick={() => (configured = false)}
|
||||
title="Server connection settings"
|
||||
>
|
||||
<SettingsIcon />
|
||||
<span>Connection</span>
|
||||
</Button>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar.Root>
|
||||
|
||||
<Sidebar.Inset class="min-h-0 overflow-hidden">
|
||||
<header class="flex h-(--header-height) shrink-0 items-center gap-1 border-b px-4 lg:gap-2 lg:px-6">
|
||||
<Sidebar.Trigger class="-ms-1" />
|
||||
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
|
||||
{#if page === 'chat'}
|
||||
{@const goalText = $currentTask?.goal ? $currentTask.goal.replace(/[*_`~#]|\[.*?\]\(.*?\)/g, '') : 'New Task'}
|
||||
<button type="button" class="shrink-0 text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Tasks</button>
|
||||
<span class="shrink-0 text-muted-foreground">/</span>
|
||||
<span class="min-w-0 flex-1 truncate text-base font-medium" title={goalText}>
|
||||
{truncateMiddle(goalText, 100)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page === 'overview' ? 'Tasks' : page}</span>
|
||||
{/if}
|
||||
</header>
|
||||
<main class="min-h-0 flex-1 overflow-hidden">
|
||||
{#if page === 'overview'}
|
||||
<Overview />
|
||||
{:else if page === 'kb'}
|
||||
<KnowledgeBase />
|
||||
{:else if page === 'entity' && routeParam}
|
||||
<EntityDetail slug={routeParam} />
|
||||
{:else if page === 'ops'}
|
||||
<Ops />
|
||||
{:else if page === 'signals'}
|
||||
<Signals />
|
||||
{:else if page === 'knowledge'}
|
||||
<Knowledge />
|
||||
{:else if page === 'learning'}
|
||||
<Learning />
|
||||
{:else}
|
||||
<Chat />
|
||||
{/if}
|
||||
</main>
|
||||
<MinimizedWindowsBar />
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
|
||||
<Sheet.Root bind:open={drawerOpen}>
|
||||
<Sheet.Content side="right" class="w-[400px] p-0 sm:max-w-[400px]">
|
||||
<Sheet.Header class="sr-only">
|
||||
<Sheet.Title>Nomos chat</Sheet.Title>
|
||||
<Sheet.Description>Persistent chat drawer</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
<div class="flex h-full flex-col">
|
||||
<Chat showRail={false} />
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
<EntityDesktop />
|
||||
|
||||
<Toaster />
|
||||
<Desktop onOpenConnection={() => (configured = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -50,6 +50,21 @@ export async function fetchMessages(sessionId: string): Promise<Message[]> {
|
||||
return data.messages ?? []
|
||||
}
|
||||
|
||||
// null distinguishes "session doesn't exist" (404 — the session was deleted,
|
||||
// or a persisted/deep-linked window id was never valid) from a transient
|
||||
// fetch failure, which should keep returning []/retrying rather than
|
||||
// permanently flip a window into a "not found" state. Only the initial
|
||||
// per-window load (chat.ts's loadSessionChat) needs this distinction — the
|
||||
// polling loops keep using fetchMessages, where swallowing a blip into []
|
||||
// and trying again next tick is the right behavior.
|
||||
export async function fetchMessagesOrNotFound(sessionId: string): Promise<Message[] | null> {
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`)
|
||||
if (res.status === 404) return null
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.messages ?? []
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<boolean> {
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
return res.ok
|
||||
|
||||
61
web/src/lib/apps.test.ts
Normal file
61
web/src/lib/apps.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// apps.ts wires in every page component for real use, but that drags a
|
||||
// heavy transitive graph into a unit test for no benefit here (and one of
|
||||
// those pages imports svelte-sonner, which fails to resolve under vitest's
|
||||
// bundled Vite — an unrelated, pre-existing package quirk). These tests only
|
||||
// care about the registry's own shape (ids, sizes, window-id helpers), so
|
||||
// stub the component imports out rather than pull all of that in.
|
||||
vi.mock('../pages/Overview.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/KnowledgeBase.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/Ops.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/Signals.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/Knowledge.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/Learning.svelte', () => ({ default: {} }))
|
||||
|
||||
import { APPS, appById, appWindowId, appIdFromWindowId } from './apps'
|
||||
|
||||
describe('APPS registry', () => {
|
||||
it('has unique, non-empty ids', () => {
|
||||
const ids = APPS.map((a) => a.id)
|
||||
expect(ids.length).toBeGreaterThan(0)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const id of ids) expect(id).not.toBe('')
|
||||
})
|
||||
|
||||
it('gives every app a positive default size', () => {
|
||||
for (const app of APPS) {
|
||||
expect(app.width).toBeGreaterThan(0)
|
||||
expect(app.height).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('is indexed by id in appById', () => {
|
||||
for (const app of APPS) {
|
||||
expect(appById.get(app.id)).toBe(app)
|
||||
}
|
||||
expect(appById.size).toBe(APPS.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appWindowId / appIdFromWindowId', () => {
|
||||
it('round-trips an app id through its window id', () => {
|
||||
for (const app of APPS) {
|
||||
expect(appIdFromWindowId(appWindowId(app.id))).toBe(app.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for ids that are not app windows', () => {
|
||||
expect(appIdFromWindowId('session:abc-123')).toBeNull()
|
||||
expect(appIdFromWindowId('host:strong')).toBeNull()
|
||||
expect(appIdFromWindowId('new-task')).toBeNull()
|
||||
})
|
||||
|
||||
it('namespaces window ids so they cannot collide with entity slugs', () => {
|
||||
// Entity slugs are bare `type:identifier` strings (see windows.ts's
|
||||
// openEntityWindow) — app window ids must never look like one.
|
||||
for (const app of APPS) {
|
||||
expect(appWindowId(app.id).startsWith('app:')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
118
web/src/lib/apps.ts
Normal file
118
web/src/lib/apps.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// The desktop's app registry — single source of truth for what shows up as
|
||||
// a desktop icon and what opens in its window. Adding a new app is one entry
|
||||
// here; nothing else needs to change (Desktop.svelte renders icons from
|
||||
// APPS, WindowLayer.svelte resolves `app:<id>` window ids back through
|
||||
// appById, Taskbar.svelte reads title/icon the same way). Compare to the old
|
||||
// App.svelte's hardcoded navItems array + if/else page branch, which required
|
||||
// touching three places (nav list, header title, main content branch) to add
|
||||
// one page.
|
||||
import type { Component } from 'svelte'
|
||||
import type { DashboardSummary } from '$lib/api'
|
||||
import { openSignalCount } from '$lib/stores/context'
|
||||
import Overview from '../pages/Overview.svelte'
|
||||
import KnowledgeBase from '../pages/KnowledgeBase.svelte'
|
||||
import Ops from '../pages/Ops.svelte'
|
||||
import Signals from '../pages/Signals.svelte'
|
||||
import Knowledge from '../pages/Knowledge.svelte'
|
||||
import Learning from '../pages/Learning.svelte'
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||
|
||||
export interface AppDef {
|
||||
id: string
|
||||
title: string
|
||||
icon: Component
|
||||
component: Component
|
||||
width: number
|
||||
height: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
// Pure function over the shared dashboard summary — used for both the
|
||||
// desktop icon's badge and the taskbar button's badge, so a new app that
|
||||
// wants one just supplies this instead of each surface reimplementing it.
|
||||
badge?: (summary: DashboardSummary | null) => number
|
||||
}
|
||||
|
||||
export const APPS: AppDef[] = [
|
||||
{
|
||||
id: 'tasks',
|
||||
title: 'Tasks',
|
||||
icon: ListTodoIcon,
|
||||
component: Overview,
|
||||
width: 960,
|
||||
height: 680,
|
||||
minWidth: 480,
|
||||
minHeight: 420
|
||||
},
|
||||
{
|
||||
id: 'kb',
|
||||
title: 'Knowledge Base',
|
||||
icon: DatabaseIcon,
|
||||
component: KnowledgeBase,
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 520,
|
||||
minHeight: 420
|
||||
},
|
||||
{
|
||||
id: 'ops',
|
||||
title: 'Operations',
|
||||
icon: ShieldCheckIcon,
|
||||
component: Ops,
|
||||
width: 860,
|
||||
height: 620,
|
||||
minWidth: 480,
|
||||
minHeight: 360,
|
||||
badge: (s) => s?.approvals_pending ?? 0
|
||||
},
|
||||
{
|
||||
id: 'signals',
|
||||
title: 'Signals',
|
||||
icon: SirenIcon,
|
||||
component: Signals,
|
||||
width: 860,
|
||||
height: 620,
|
||||
minWidth: 480,
|
||||
minHeight: 360,
|
||||
badge: (s) => openSignalCount(s)
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
title: 'Knowledge',
|
||||
icon: SearchIcon,
|
||||
component: Knowledge,
|
||||
width: 800,
|
||||
height: 600,
|
||||
minWidth: 440,
|
||||
minHeight: 340
|
||||
},
|
||||
{
|
||||
id: 'learning',
|
||||
title: 'Learning',
|
||||
icon: TrendingUpIcon,
|
||||
component: Learning,
|
||||
width: 800,
|
||||
height: 600,
|
||||
minWidth: 440,
|
||||
minHeight: 340
|
||||
}
|
||||
]
|
||||
|
||||
export const appById = new Map(APPS.map((a) => [a.id, a]))
|
||||
|
||||
// Window ids are namespaced so WindowLayer.svelte can tell at a glance which
|
||||
// content branch owns an id: `app:<id>` for registry apps, `session:<id>`
|
||||
// for task chat windows (see windows.ts), anything else is an entity slug.
|
||||
const APP_PREFIX = 'app:'
|
||||
|
||||
export function appWindowId(id: string): string {
|
||||
return `${APP_PREFIX}${id}`
|
||||
}
|
||||
|
||||
export function appIdFromWindowId(windowId: string): string | null {
|
||||
return windowId.startsWith(APP_PREFIX) ? windowId.slice(APP_PREFIX.length) : null
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
// Global floating-window layer — mounted once in App.svelte, above every
|
||||
// page, so an entity opened from Knowledge Base, chat, or anywhere else
|
||||
// lands in the same window stack instead of each page owning its own
|
||||
// single-entity sidebar/sheet. See $lib/stores/windows.ts. Minimized
|
||||
// windows reopen from MinimizedWindowsBar, mounted separately inside the
|
||||
// page layout (not here) so it takes real space instead of floating over
|
||||
// content — see that component for why.
|
||||
import { dk, wmState, openEntityWindow } from '$lib/stores/windows'
|
||||
import EntityDetailContent from './EntityDetailContent.svelte'
|
||||
import SessionChatWindow from './SessionChatWindow.svelte'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import MinusIcon from '@lucide/svelte/icons/minus'
|
||||
|
||||
const SESSION_PREFIX = 'session:'
|
||||
</script>
|
||||
|
||||
<div use:dk.desktop class="pointer-events-none fixed inset-0 z-40">
|
||||
{#each $wmState.order as id (id)}
|
||||
{@const win = $wmState.windows[id]}
|
||||
{#if win}
|
||||
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
|
||||
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
|
||||
<span data-wm-title class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium">{win.title}</span>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
data-wm-minimize
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Minimize {win.title}"
|
||||
>
|
||||
<MinusIcon class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-wm-close
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label="Close {win.title}"
|
||||
>
|
||||
<XIcon class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div data-wm-content class="min-h-0 flex-1 overflow-hidden">
|
||||
{#key id}
|
||||
{#if id.startsWith(SESSION_PREFIX)}
|
||||
<SessionChatWindow sessionId={id.slice(SESSION_PREFIX.length)} />
|
||||
{:else}
|
||||
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
// Deliberately mounted inside the page layout (App.svelte's Sidebar.Inset
|
||||
// column, not alongside EntityDesktop's full-viewport overlay) — as a real
|
||||
// flex child it takes its own height and the page content above it
|
||||
// (min-h-0 flex-1) shrinks to make room, instead of floating over
|
||||
// whatever's scrolled to the bottom.
|
||||
import { wm, wmState } from '$lib/stores/windows'
|
||||
import { truncateMiddle } from '$lib/utils'
|
||||
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
|
||||
|
||||
const minimizedIds = $derived($wmState.order.filter((id) => $wmState.windows[id]?.stage === 'minimized'))
|
||||
|
||||
function restoreWindow(id: string) {
|
||||
wm.restore(id)
|
||||
wm.focus(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if minimizedIds.length}
|
||||
<div class="flex shrink-0 items-center gap-1.5 overflow-x-auto border-t bg-muted/30 px-2 py-1.5">
|
||||
{#each minimizedIds as id (id)}
|
||||
{@const win = $wmState.windows[id]}
|
||||
{#if win}
|
||||
<button
|
||||
type="button"
|
||||
class="group flex max-w-56 shrink-0 items-center gap-2 rounded-md border bg-card py-1 pr-1.5 pl-2.5 font-mono text-xs hover:bg-muted"
|
||||
onclick={() => restoreWindow(id)}
|
||||
title={win.title}
|
||||
>
|
||||
<span class="min-w-0 truncate">{truncateMiddle(win.title, 32)}</span>
|
||||
<Maximize2Icon class="size-3 shrink-0 text-muted-foreground group-hover:text-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -20,6 +20,7 @@
|
||||
const chatStreaming = chat.streaming
|
||||
const chatConnectionState = chat.connectionState
|
||||
const chatError = chat.error
|
||||
const chatNotFound = chat.notFound
|
||||
let loading = $state(true)
|
||||
|
||||
onMount(async () => {
|
||||
@@ -57,6 +58,11 @@
|
||||
<div class="flex h-full min-h-0">
|
||||
{#if loading}
|
||||
<div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">Loading…</div>
|
||||
{:else if $chatNotFound}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-1 p-6 text-center">
|
||||
<p class="text-sm text-muted-foreground">Task not found.</p>
|
||||
<p class="text-xs text-muted-foreground/70">It may have been deleted.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ChatThread
|
||||
messages={$chatMessages}
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
})
|
||||
|
||||
// The highlight ring/dim styling below is tied to the node whose window
|
||||
// was last opened — once that window is closed (from EntityDesktop, not
|
||||
// was last opened — once that window is closed (from WindowLayer, not
|
||||
// necessarily from here), the ring should go with it rather than pointing
|
||||
// at a window that no longer exists.
|
||||
$effect(() => {
|
||||
@@ -265,7 +265,7 @@
|
||||
|
||||
// ─── drag / select ───────────────────────────────────────────────────
|
||||
// A click (pointerdown+up with no movement in between) opens the entity
|
||||
// straight in its own floating window (EntityDesktop) instead of a
|
||||
// straight in its own floating window (WindowLayer) instead of a
|
||||
// click-through mini-panel — `selected` now only drives the highlight/dim
|
||||
// styling below, so you can see at a glance which node you last opened.
|
||||
let dragState: { node: Node; moved: boolean } | null = null
|
||||
|
||||
163
web/src/lib/components/desktop-shell/Desktop.svelte
Normal file
163
web/src/lib/components/desktop-shell/Desktop.svelte
Normal file
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
// The desktop shell: full-viewport surface (background + icons + the
|
||||
// centered task launcher + the floating window layer) with the taskbar
|
||||
// docked below it as a real flex sibling, not an overlay — so a maximized
|
||||
// or dragged window can never end up underneath the taskbar. This replaces
|
||||
// the old sidebar + hash-routed page shell in App.svelte entirely; apps are
|
||||
// desktop icons now (see $lib/apps.ts), not nav items.
|
||||
import { APPS } from '$lib/apps'
|
||||
import { iconPositions, resetIconLayout } from '$lib/stores/icons'
|
||||
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
|
||||
import { summary } from '$lib/stores/context'
|
||||
import GraphBackground from '../GraphBackground.svelte'
|
||||
import DesktopIcon from './DesktopIcon.svelte'
|
||||
import TaskLauncher from './TaskLauncher.svelte'
|
||||
import WindowLayer from './WindowLayer.svelte'
|
||||
import Taskbar from './Taskbar.svelte'
|
||||
import LayersIcon from '@lucide/svelte/icons/layers'
|
||||
import Rows3Icon from '@lucide/svelte/icons/rows-3'
|
||||
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
||||
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
|
||||
import Undo2Icon from '@lucide/svelte/icons/undo-2'
|
||||
import Redo2Icon from '@lucide/svelte/icons/redo-2'
|
||||
|
||||
let { onOpenConnection }: { onOpenConnection: () => void } = $props()
|
||||
|
||||
// Clicking the bare desktop (not an icon, not a window) blurs the focused
|
||||
// window — the familiar "click empty desktop to deselect" affordance.
|
||||
function onSurfaceClick(e: MouseEvent) {
|
||||
if (e.currentTarget === e.target) wm.blur()
|
||||
}
|
||||
|
||||
// Right-click menu, bare desktop only (same currentTarget===target gate as
|
||||
// onSurfaceClick above — icons and windows sit on pointer-events-auto
|
||||
// layers above the otherwise pointer-events-none surface, so a right-click
|
||||
// that lands on either of them never reaches here). canUndo/canRedo are
|
||||
// plain wmkit method calls (not stores), so they're snapshotted once at
|
||||
// open time rather than read reactively in the template.
|
||||
let menuPos = $state<{ x: number; y: number } | null>(null)
|
||||
let menuCanUndo = $state(false)
|
||||
let menuCanRedo = $state(false)
|
||||
|
||||
function onSurfaceContextMenu(e: MouseEvent) {
|
||||
if (e.currentTarget !== e.target) return
|
||||
e.preventDefault()
|
||||
menuCanUndo = wm.canUndo()
|
||||
menuCanRedo = wm.canRedo()
|
||||
menuPos = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuPos = null
|
||||
}
|
||||
|
||||
function runMenuAction(fn: () => void) {
|
||||
fn()
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Z / Shift+Z for window-arrangement undo/redo (move, resize,
|
||||
// close, ...) — wmkit tracks this history but ships no default keybinding.
|
||||
// Skipped entirely while an editable element has focus so it never
|
||||
// fights the browser's own text-undo inside the task input or a form
|
||||
// field.
|
||||
function onWindowKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && menuPos) {
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
const target = e.target as HTMLElement | null
|
||||
const editable = !!target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
if (editable) return
|
||||
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'z') return
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) wm.redo()
|
||||
else wm.undo()
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} onclick={closeMenu} />
|
||||
|
||||
<div class="fixed inset-0 flex flex-col">
|
||||
<div
|
||||
class="relative min-h-0 flex-1 overflow-hidden"
|
||||
role="presentation"
|
||||
onclick={onSurfaceClick}
|
||||
oncontextmenu={onSurfaceContextMenu}
|
||||
>
|
||||
<GraphBackground />
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 z-0">
|
||||
{#each APPS as app (app.id)}
|
||||
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
|
||||
{@const badge = app.badge?.($summary) ?? 0}
|
||||
<DesktopIcon {app} {pos} {badge} onOpen={() => openAppWindow(app.id)} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-6">
|
||||
<div class="pointer-events-auto">
|
||||
<TaskLauncher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WindowLayer />
|
||||
</div>
|
||||
|
||||
<Taskbar {onOpenConnection} />
|
||||
</div>
|
||||
|
||||
{#if menuPos}
|
||||
<div
|
||||
class="fixed z-50 min-w-48 rounded-md border bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
||||
style="left: {menuPos.x}px; top: {menuPos.y}px"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(() => wm.arrange('cascade'))}
|
||||
>
|
||||
<LayersIcon class="size-4" /> Cascade windows
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(() => wm.arrange('tile'))}
|
||||
>
|
||||
<Rows3Icon class="size-4" /> Tile windows
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(toggleShowDesktop)}
|
||||
>
|
||||
<MonitorIcon class="size-4" /> Show desktop
|
||||
</button>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(resetIconLayout)}
|
||||
>
|
||||
<RotateCcwIcon class="size-4" /> Reset icon layout
|
||||
</button>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!menuCanUndo}
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
onclick={() => runMenuAction(() => wm.undo())}
|
||||
>
|
||||
<Undo2Icon class="size-4" /> Undo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!menuCanRedo}
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
onclick={() => runMenuAction(() => wm.redo())}
|
||||
>
|
||||
<Redo2Icon class="size-4" /> Redo
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
100
web/src/lib/components/desktop-shell/DesktopIcon.svelte
Normal file
100
web/src/lib/components/desktop-shell/DesktopIcon.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
// A single desktop icon: positioned from the grid store, draggable to any
|
||||
// free cell, opens its app on a plain click. wmkit has nothing to do with
|
||||
// icons — they're a flat non-overlapping grid, not floating/resizable
|
||||
// windows, so this is a small self-contained pointer-drag implementation
|
||||
// rather than pressing wmkit's window abstractions into a shape they don't
|
||||
// fit. See $lib/stores/icons.ts for the grid model + persistence.
|
||||
import { GRID, iconPixelPos, placeIcon, type IconPos } from '$lib/stores/icons'
|
||||
import type { AppDef } from '$lib/apps'
|
||||
|
||||
let {
|
||||
app,
|
||||
pos,
|
||||
badge = 0,
|
||||
onOpen
|
||||
}: { app: AppDef; pos: IconPos; badge?: number; onOpen: () => void } = $props()
|
||||
|
||||
const DRAG_THRESHOLD = 5
|
||||
|
||||
let dragging = $state(false)
|
||||
let dragPos = $state<{ x: number; y: number } | null>(null)
|
||||
|
||||
function toCell(x: number, y: number): IconPos {
|
||||
return {
|
||||
col: Math.round((x - GRID.padding) / (GRID.cell + GRID.gap)),
|
||||
row: Math.round((y - GRID.padding) / (GRID.cell + GRID.gap))
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const origin = iconPixelPos(pos)
|
||||
let moved = false
|
||||
|
||||
el.setPointerCapture(e.pointerId)
|
||||
|
||||
function onMove(ev: PointerEvent) {
|
||||
const dx = ev.clientX - startX
|
||||
const dy = ev.clientY - startY
|
||||
if (!moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) {
|
||||
moved = true
|
||||
dragging = true
|
||||
}
|
||||
if (moved) {
|
||||
dragPos = { x: origin.x + dx, y: origin.y + dy }
|
||||
}
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerup', onUp)
|
||||
if (moved && dragPos) {
|
||||
const cell = toCell(dragPos.x, dragPos.y)
|
||||
placeIcon(app.id, cell.col, cell.row)
|
||||
} else {
|
||||
onOpen()
|
||||
}
|
||||
dragging = false
|
||||
dragPos = null
|
||||
}
|
||||
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerup', onUp)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpen()
|
||||
}
|
||||
}
|
||||
|
||||
const restPos = $derived(iconPixelPos(pos))
|
||||
const left = $derived(dragging && dragPos ? dragPos.x : restPos.x)
|
||||
const top = $derived(dragging && dragPos ? dragPos.y : restPos.y)
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="group absolute flex flex-col items-center gap-1 rounded-lg p-1.5 pointer-events-auto select-none focus-visible:outline-2 focus-visible:outline-ring {dragging
|
||||
? 'z-50 cursor-grabbing bg-accent/40'
|
||||
: 'cursor-pointer hover:bg-accent/30'}"
|
||||
style="left: {left}px; top: {top}px; width: {GRID.cell}px;"
|
||||
onpointerdown={onPointerDown}
|
||||
onkeydown={onKeydown}
|
||||
title={app.title}
|
||||
>
|
||||
<span class="relative flex size-10 items-center justify-center rounded-xl border bg-card/80 text-foreground shadow-sm backdrop-blur">
|
||||
<app.icon class="size-5" />
|
||||
{#if badge > 0}
|
||||
<span class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="max-w-full truncate text-[11px] text-foreground/90">{app.title}</span>
|
||||
</button>
|
||||
62
web/src/lib/components/desktop-shell/TaskLauncher.svelte
Normal file
62
web/src/lib/components/desktop-shell/TaskLauncher.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
// "What should Nomos do?" — the desktop's centerpiece. Extracted from the
|
||||
// old Overview page hero so both the desktop surface and the Tasks app
|
||||
// window can mount it; startTask() (see $lib/stores/chat.ts) begins the
|
||||
// stream immediately and hands back the session id once the backend
|
||||
// assigns one, which is the earliest point a task window can be opened.
|
||||
import { startTask } from '$lib/stores/chat'
|
||||
import { openTaskWindow } from '$lib/stores/windows'
|
||||
import { truncateMiddle } from '$lib/utils'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
|
||||
let { compact = false, onStarted }: { compact?: boolean; onStarted?: () => void } = $props()
|
||||
|
||||
let input = $state('')
|
||||
|
||||
function submit() {
|
||||
const text = input.trim()
|
||||
if (!text) return
|
||||
input = ''
|
||||
startTask(text, (sessionId) => openTaskWindow(sessionId, truncateMiddle(text, 60)))
|
||||
onStarted?.()
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full max-w-2xl text-center">
|
||||
{#if !compact}
|
||||
<h1 class="mb-1 text-2xl font-semibold tracking-tight">What should Nomos do?</h1>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Describe a goal — Nomos will plan it, execute it, and report the outcome.
|
||||
</p>
|
||||
{/if}
|
||||
<form
|
||||
class="relative rounded-2xl border bg-card/70 shadow-lg backdrop-blur focus-within:border-primary/60"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
bind:value={input}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="e.g. Roll the staging database back to last night's snapshot and verify the app is healthy…"
|
||||
rows={compact ? 2 : 3}
|
||||
class="max-h-52 min-h-24 resize-none border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
<div class="flex items-center justify-between px-3 pb-3">
|
||||
<span class="text-[11px] text-muted-foreground">Enter to start · Shift+Enter for newline</span>
|
||||
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Start task">
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
131
web/src/lib/components/desktop-shell/Taskbar.svelte
Normal file
131
web/src/lib/components/desktop-shell/Taskbar.svelte
Normal file
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
// Bottom taskbar: a real flex row in the page layout (not an overlay), so
|
||||
// windows can never be dragged/maximized underneath it — see Desktop.svelte
|
||||
// for how the window layer's bounds are scoped to the surface above this.
|
||||
// Shows a button for every open window (not just minimized ones — compare
|
||||
// to the old MinimizedWindowsBar, which only ever showed minimized windows
|
||||
// and gave no way to see/switch between windows that were merely
|
||||
// unfocused), plus a system tray for theme/connection/version.
|
||||
import { wm, wmState, toggleShowDesktop } from '$lib/stores/windows'
|
||||
import { appById, appIdFromWindowId } from '$lib/apps'
|
||||
import { summary } from '$lib/stores/context'
|
||||
import { truncateMiddle } from '$lib/utils'
|
||||
import { getTheme, toggleTheme, THEME_LABELS } from '$lib/stores/theme.svelte'
|
||||
import { VERSION } from '$lib/version'
|
||||
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||
import PaletteIcon from '@lucide/svelte/icons/palette'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { onOpenConnection }: { onOpenConnection: () => void } = $props()
|
||||
|
||||
const SESSION_PREFIX = 'session:'
|
||||
|
||||
const buttons = $derived(
|
||||
[...$wmState.order]
|
||||
.map((id) => $wmState.windows[id])
|
||||
.filter((w): w is NonNullable<typeof w> => !!w)
|
||||
.sort((a, b) => a.openedSeq - b.openedSeq)
|
||||
)
|
||||
|
||||
function iconFor(id: string) {
|
||||
const appId = appIdFromWindowId(id)
|
||||
if (appId) return appById.get(appId)?.icon
|
||||
if (id.startsWith(SESSION_PREFIX)) return MessageSquareIcon
|
||||
return DatabaseIcon
|
||||
}
|
||||
|
||||
function badgeFor(id: string): number {
|
||||
const appId = appIdFromWindowId(id)
|
||||
const app = appId ? appById.get(appId) : undefined
|
||||
return app?.badge?.($summary) ?? 0
|
||||
}
|
||||
|
||||
function toggle(id: string, win: (typeof buttons)[number]) {
|
||||
if (win.stage === 'minimized') {
|
||||
wm.restore(id)
|
||||
wm.focus(id)
|
||||
} else if ($wmState.focusedId === id) {
|
||||
wm.minimize(id)
|
||||
} else {
|
||||
wm.focus(id)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t bg-muted/30 px-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onclick={toggleShowDesktop}
|
||||
title="Show desktop"
|
||||
>
|
||||
<LayoutGridIcon class="size-4" />
|
||||
</button>
|
||||
|
||||
<div class="h-6 w-px shrink-0 bg-border"></div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
||||
{#each buttons as win (win.id)}
|
||||
{@const Icon = iconFor(win.id)}
|
||||
{@const badge = badgeFor(win.id)}
|
||||
<div class="group/tb relative flex shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
data-taskbar-btn={win.id}
|
||||
class="flex h-8 max-w-56 items-center gap-1.5 rounded-md border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
|
||||
win.id && win.stage !== 'minimized'
|
||||
? 'border-primary/50 bg-primary/10 text-foreground'
|
||||
: 'border-transparent bg-card/60 text-muted-foreground hover:bg-muted'} {win.stage === 'minimized' ? 'opacity-60' : ''}"
|
||||
onclick={() => toggle(win.id, win)}
|
||||
title={win.title}
|
||||
>
|
||||
{#if Icon}<Icon class="size-3.5 shrink-0" />{/if}
|
||||
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
|
||||
{#if badge > 0}
|
||||
<span class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute -top-1.5 -right-1.5 hidden size-4 items-center justify-center rounded-full bg-muted-foreground/80 text-background hover:bg-destructive group-hover/tb:flex"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
wm.close(win.id)
|
||||
}}
|
||||
aria-label="Close {win.title}"
|
||||
>
|
||||
<XIcon class="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="h-6 w-px shrink-0 bg-border"></div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center gap-1.5 rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onclick={() => toggleTheme()}
|
||||
title="Cycle theme"
|
||||
>
|
||||
<PaletteIcon class="size-4" />
|
||||
<span class="hidden text-xs sm:inline">{THEME_LABELS[getTheme()]}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onclick={onOpenConnection}
|
||||
title="Server connection settings"
|
||||
>
|
||||
<SettingsIcon class="size-4" />
|
||||
</button>
|
||||
<span class="px-1.5 text-[11px] text-muted-foreground select-none">{VERSION}</span>
|
||||
</div>
|
||||
</div>
|
||||
89
web/src/lib/components/desktop-shell/WindowLayer.svelte
Normal file
89
web/src/lib/components/desktop-shell/WindowLayer.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
// The floating-window layer — mounted once inside Desktop.svelte, above the
|
||||
// icons layer, so every window (an app, a task, an entity detail) shares
|
||||
// one stack instead of each page owning its own single-entity
|
||||
// sidebar/sheet. See $lib/stores/windows.ts. Content is resolved purely
|
||||
// from the window's id, which is why persisted/hydrated windows (see
|
||||
// wmPersist in windows.ts) need no extra bookkeeping to know what to render:
|
||||
// app:<id> -> registry component (windows.ts openAppWindow)
|
||||
// session:<id> -> SessionChatWindow (windows.ts openTaskWindow)
|
||||
// new-task -> TaskLauncher (windows.ts openNewTaskWindow)
|
||||
// anything else -> entity slug -> EntityDetailContent
|
||||
import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID } from '$lib/stores/windows'
|
||||
import { appById, appIdFromWindowId } from '$lib/apps'
|
||||
import EntityDetailContent from '../EntityDetailContent.svelte'
|
||||
import SessionChatWindow from '../SessionChatWindow.svelte'
|
||||
import TaskLauncher from './TaskLauncher.svelte'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import MinusIcon from '@lucide/svelte/icons/minus'
|
||||
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
|
||||
|
||||
const SESSION_PREFIX = 'session:'
|
||||
|
||||
// A hydrated `app:<id>` window whose id no longer matches any registry
|
||||
// entry (the app was renamed/removed since the layout was persisted) has
|
||||
// nothing to render — close it rather than leaving a permanently-blank
|
||||
// window stuck in the taskbar.
|
||||
$effect(() => {
|
||||
for (const id of $wmState.order) {
|
||||
const appId = appIdFromWindowId(id)
|
||||
if (appId && !appById.has(appId)) wm.close(id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
|
||||
{#each $wmState.order as id (id)}
|
||||
{@const win = $wmState.windows[id]}
|
||||
{@const appId = appIdFromWindowId(id)}
|
||||
{@const app = appId ? appById.get(appId) : undefined}
|
||||
{#if win && (!appId || app)}
|
||||
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
|
||||
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
|
||||
<span data-wm-title class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium">{win.title}</span>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
data-wm-minimize
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Minimize {win.title}"
|
||||
>
|
||||
<MinusIcon class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-wm-maximize
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Maximize {win.title}"
|
||||
>
|
||||
<Maximize2Icon class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-wm-close
|
||||
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label="Close {win.title}"
|
||||
>
|
||||
<XIcon class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div data-wm-content class="min-h-0 flex-1 overflow-hidden">
|
||||
{#key id}
|
||||
{#if id.startsWith(SESSION_PREFIX)}
|
||||
<SessionChatWindow sessionId={id.slice(SESSION_PREFIX.length)} />
|
||||
{:else if id === NEW_TASK_WINDOW_ID}
|
||||
<div class="flex h-full items-center justify-center p-6">
|
||||
<TaskLauncher onStarted={() => wm.close(NEW_TASK_WINDOW_ID)} />
|
||||
</div>
|
||||
{:else if app}
|
||||
<app.component />
|
||||
{:else}
|
||||
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writable, get, type Writable } from 'svelte/store'
|
||||
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
|
||||
import { streamChat, fetchSessions, fetchMessages, fetchMessagesOrNotFound, deleteSession as apiDeleteSession } from '$lib/api'
|
||||
import type { ChatEvent, Session, Message } from '$lib/api'
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
|
||||
@@ -499,6 +499,11 @@ export interface SessionChatState {
|
||||
streaming: Writable<boolean>
|
||||
connectionState: Writable<'connected' | 'disconnected' | 'reconnecting'>
|
||||
error: Writable<string | null>
|
||||
// Set by loadSessionChat when the backend 404s the session outright
|
||||
// (deleted, or an id that was never valid — a stale persisted window, a
|
||||
// bad deep link). Distinct from a merely-empty transcript, which is the
|
||||
// normal state for a session that exists but hasn't sent a message yet.
|
||||
notFound: Writable<boolean>
|
||||
}
|
||||
|
||||
const sessionChats = new Map<string, SessionChatState>()
|
||||
@@ -509,7 +514,13 @@ const sessionPollers = new Map<string, ReturnType<typeof setInterval>>()
|
||||
export function chatFor(sessionId: string): SessionChatState {
|
||||
let c = sessionChats.get(sessionId)
|
||||
if (!c) {
|
||||
c = { messages: writable([]), streaming: writable(false), connectionState: writable('connected'), error: writable(null) }
|
||||
c = {
|
||||
messages: writable([]),
|
||||
streaming: writable(false),
|
||||
connectionState: writable('connected'),
|
||||
error: writable(null),
|
||||
notFound: writable(false)
|
||||
}
|
||||
sessionChats.set(sessionId, c)
|
||||
}
|
||||
return c
|
||||
@@ -544,7 +555,11 @@ export function stopSessionPolling(sessionId: string) {
|
||||
export async function loadSessionChat(sessionId: string): Promise<void> {
|
||||
const chat = chatFor(sessionId)
|
||||
chat.streaming.set(false)
|
||||
const msgs = await fetchMessages(sessionId)
|
||||
const msgs = await fetchMessagesOrNotFound(sessionId)
|
||||
if (msgs === null) {
|
||||
chat.notFound.set(true)
|
||||
return // nothing to poll — the session doesn't exist
|
||||
}
|
||||
chat.messages.set(toChatMessages(msgs))
|
||||
startSessionPolling(sessionId)
|
||||
}
|
||||
@@ -650,3 +665,117 @@ export function cancelSessionStream(sessionId: string) {
|
||||
activeControllers.delete(sessionId)
|
||||
chatFor(sessionId).streaming.set(false)
|
||||
}
|
||||
|
||||
// ─── new-task launcher (desktop center input / Tasks app) ───────────────────
|
||||
//
|
||||
// Starting a brand-new task has no session id to hang a window off of until
|
||||
// the stream's own 'session' event assigns one (see the 'session' branch in
|
||||
// sendMessage above) — the desktop launcher needs to open that task's window
|
||||
// the moment an id exists, not before. startTask begins the stream
|
||||
// immediately, buffers any events that arrive before 'session' (defensive:
|
||||
// in practice 'session' always arrives first), then seeds that session's own
|
||||
// chatFor() bundle exactly like sendSessionMessage does and hands the id back
|
||||
// via onSession so the caller can open its window. From that point on the
|
||||
// window behaves exactly like any other task window.
|
||||
export function startTask(text: string, onSession: (sessionId: string) => void): void {
|
||||
const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] }
|
||||
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
|
||||
const activeTools: Map<string, ToolCallResult> = new Map()
|
||||
let receivedDone = false
|
||||
let sessionId: string | null = null
|
||||
let chat: SessionChatState | null = null
|
||||
const buffered: ChatEvent[] = []
|
||||
|
||||
function apply(ev: ChatEvent) {
|
||||
const c = chat
|
||||
if (!c || !sessionId) return
|
||||
if (ev.type === 'tool_use') {
|
||||
const tr: ToolCallResult = { type: 'tool_use', name: ev.data.name, id: ev.data.id, args: ev.data.args }
|
||||
activeTools.set(ev.data.id, tr)
|
||||
c.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'tool_result') {
|
||||
const existing = activeTools.get(ev.data.id)
|
||||
if (existing) {
|
||||
const updated: ToolCallResult = { ...existing, type: 'tool_result', result: ev.data.result, error: ev.data.error }
|
||||
activeTools.set(ev.data.id, updated)
|
||||
c.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
|
||||
last.pendingApprovals = extractApprovals(last.tools)
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
}
|
||||
} else if (ev.type === 'text_delta') {
|
||||
c.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.text += ev.data
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'text') {
|
||||
c.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.text = ev.data
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'done') {
|
||||
receivedDone = true
|
||||
c.connectionState.set('connected')
|
||||
c.messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
|
||||
return [...ms]
|
||||
})
|
||||
startSessionPolling(sessionId)
|
||||
} else if (ev.type === 'error') {
|
||||
c.error.set(ev.data)
|
||||
}
|
||||
}
|
||||
|
||||
const controller = streamChat(
|
||||
text,
|
||||
null,
|
||||
(ev: ChatEvent) => {
|
||||
if (ev.type === 'session') {
|
||||
sessionId = ev.data
|
||||
activeControllers.set(sessionId, controller)
|
||||
chat = chatFor(sessionId)
|
||||
chat.streaming.set(true)
|
||||
chat.messages.update((ms) => [...ms, userMsg, assistantMsg])
|
||||
onSession(sessionId)
|
||||
for (const b of buffered.splice(0)) apply(b)
|
||||
return
|
||||
}
|
||||
if (!chat) {
|
||||
buffered.push(ev)
|
||||
return
|
||||
}
|
||||
apply(ev)
|
||||
},
|
||||
(err: string) => {
|
||||
if (!chat) return // never got a session id — nothing to show the error in
|
||||
if (err === 'AbortError' || err.includes('aborted')) {
|
||||
chat.streaming.set(false)
|
||||
return
|
||||
}
|
||||
chat.error.set(err)
|
||||
if (!receivedDone && sessionId) {
|
||||
chat.connectionState.set('disconnected')
|
||||
startSessionPolling(sessionId)
|
||||
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
|
||||
} else {
|
||||
chat.streaming.set(false)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (chat) chat.streaming.set(false)
|
||||
if (sessionId && activeControllers.get(sessionId) === controller) activeControllers.delete(sessionId)
|
||||
loadSessions()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
95
web/src/lib/stores/icons.test.ts
Normal file
95
web/src/lib/stores/icons.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// icons.ts only needs APPS for its default-layout ids — stub it out rather
|
||||
// than pull in the real registry's full page-component graph (see
|
||||
// apps.test.ts for why that graph is expensive/broken under vitest).
|
||||
vi.mock('$lib/apps', () => ({
|
||||
APPS: [{ id: 'tasks' }, { id: 'kb' }, { id: 'ops' }, { id: 'signals' }, { id: 'knowledge' }, { id: 'learning' }]
|
||||
}))
|
||||
|
||||
import { GRID, iconPositions, placeIcon, iconPixelPos, maxCols, getIconPositions, resetIconLayout } from './icons'
|
||||
|
||||
// placeIcon mutates the shared module-level store, so each test starts from
|
||||
// a known, empty layout rather than whatever the previous test (or apps.ts's
|
||||
// registry-derived defaults) left behind.
|
||||
beforeEach(() => {
|
||||
iconPositions.set({})
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('iconPixelPos', () => {
|
||||
it('converts a grid cell to pixel coordinates using GRID constants', () => {
|
||||
expect(iconPixelPos({ col: 0, row: 0 })).toEqual({ x: GRID.padding, y: GRID.padding })
|
||||
expect(iconPixelPos({ col: 1, row: 2 })).toEqual({
|
||||
x: GRID.padding + (GRID.cell + GRID.gap),
|
||||
y: GRID.padding + 2 * (GRID.cell + GRID.gap)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('maxCols', () => {
|
||||
it('computes how many columns fit in a viewport width', () => {
|
||||
const cellSpan = GRID.cell + GRID.gap
|
||||
expect(maxCols(GRID.padding + cellSpan * 3)).toBe(3)
|
||||
})
|
||||
|
||||
it('never returns less than 1, even for a tiny viewport', () => {
|
||||
expect(maxCols(0)).toBe(1)
|
||||
expect(maxCols(GRID.padding)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeIcon', () => {
|
||||
it('places an icon at the requested cell when it is free', () => {
|
||||
placeIcon('tasks', 3, 4)
|
||||
expect(getIconPositions().tasks).toEqual({ col: 3, row: 4 })
|
||||
})
|
||||
|
||||
it('clamps negative coordinates to 0 for an otherwise-free cell', () => {
|
||||
placeIcon('tasks', -5, -2)
|
||||
expect(getIconPositions().tasks).toEqual({ col: 0, row: 0 })
|
||||
})
|
||||
|
||||
it('nudges to the nearest free cell when the target is occupied', () => {
|
||||
iconPositions.set({ kb: { col: 2, row: 2 } })
|
||||
placeIcon('tasks', 2, 2)
|
||||
const pos = getIconPositions().tasks
|
||||
// Must not land on top of kb, and must be one of the 8 immediate
|
||||
// neighbors (radius-1 ring) since all of them are free.
|
||||
expect(pos).not.toEqual({ col: 2, row: 2 })
|
||||
expect(Math.max(Math.abs(pos.col - 2), Math.abs(pos.row - 2))).toBe(1)
|
||||
})
|
||||
|
||||
it('does not disturb the icon already occupying a cell when another icon is nudged past it', () => {
|
||||
iconPositions.set({ kb: { col: 2, row: 2 } })
|
||||
placeIcon('tasks', 2, 2)
|
||||
expect(getIconPositions().kb).toEqual({ col: 2, row: 2 })
|
||||
})
|
||||
|
||||
it('moving an icon back onto its own current cell is a no-op collision (never nudges against itself)', () => {
|
||||
iconPositions.set({ tasks: { col: 5, row: 5 } })
|
||||
placeIcon('tasks', 5, 5)
|
||||
expect(getIconPositions().tasks).toEqual({ col: 5, row: 5 })
|
||||
})
|
||||
|
||||
it('persists the updated layout to localStorage', () => {
|
||||
placeIcon('tasks', 1, 1)
|
||||
const stored = JSON.parse(localStorage.getItem('oikos-desktop-icons') ?? '{}')
|
||||
expect(stored.tasks).toEqual({ col: 1, row: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetIconLayout', () => {
|
||||
it('restores the classic left-edge column in registry order', () => {
|
||||
iconPositions.set({ tasks: { col: 4, row: 7 }, kb: { col: 1, row: 1 } })
|
||||
resetIconLayout()
|
||||
expect(getIconPositions()).toEqual({
|
||||
tasks: { col: 0, row: 0 },
|
||||
kb: { col: 0, row: 1 },
|
||||
ops: { col: 0, row: 2 },
|
||||
signals: { col: 0, row: 3 },
|
||||
knowledge: { col: 0, row: 4 },
|
||||
learning: { col: 0, row: 5 }
|
||||
})
|
||||
})
|
||||
})
|
||||
113
web/src/lib/stores/icons.ts
Normal file
113
web/src/lib/stores/icons.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Desktop icon positions — a simple column/row grid, persisted to
|
||||
// localStorage so icons stay where the operator put them across reloads.
|
||||
// Deliberately NOT wmkit: wmkit manages floating windows (pixel bounds,
|
||||
// z-order, stage), icons are a flat, non-overlapping grid with a much
|
||||
// simpler drag model (snap-to-cell, no resize/minimize/stack). Reinventing
|
||||
// that inside wmkit would mean fighting its window-shaped abstractions for
|
||||
// no benefit.
|
||||
import { writable, get } from 'svelte/store'
|
||||
import { APPS } from '$lib/apps'
|
||||
|
||||
export interface IconPos {
|
||||
col: number
|
||||
row: number
|
||||
}
|
||||
|
||||
export const GRID = { cell: 96, gap: 12, padding: 16 }
|
||||
|
||||
const STORAGE_KEY = 'oikos-desktop-icons'
|
||||
|
||||
function defaultPositions(): Record<string, IconPos> {
|
||||
// Classic OS default: one left-edge column, registry order.
|
||||
const out: Record<string, IconPos> = {}
|
||||
APPS.forEach((app, i) => {
|
||||
out[app.id] = { col: 0, row: i }
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function load(): Record<string, IconPos> {
|
||||
if (typeof localStorage === 'undefined') return defaultPositions()
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return defaultPositions()
|
||||
const parsed = JSON.parse(raw) as Record<string, IconPos>
|
||||
const out = defaultPositions()
|
||||
// Merge over the defaults so a newly-registered app (not in the saved
|
||||
// blob yet) still gets a sane starting position instead of being absent
|
||||
// from the grid entirely.
|
||||
for (const [id, pos] of Object.entries(parsed)) {
|
||||
if (appIds.has(id)) out[id] = pos
|
||||
}
|
||||
return out
|
||||
} catch {
|
||||
return defaultPositions()
|
||||
}
|
||||
}
|
||||
|
||||
const appIds = new Set(APPS.map((a) => a.id))
|
||||
|
||||
export const iconPositions = writable<Record<string, IconPos>>(load())
|
||||
|
||||
function persist(positions: Record<string, IconPos>): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
|
||||
}
|
||||
|
||||
iconPositions.subscribe((positions) => persist(positions))
|
||||
|
||||
function occupied(positions: Record<string, IconPos>, col: number, row: number, exceptId: string): boolean {
|
||||
return Object.entries(positions).some(([id, p]) => id !== exceptId && p.col === col && p.row === row)
|
||||
}
|
||||
|
||||
// Finds the nearest free cell to (col, row) via an expanding ring search,
|
||||
// so dropping an icon onto an occupied cell nudges it to the closest open
|
||||
// spot instead of silently overlapping or refusing the drop.
|
||||
function nearestFreeCell(
|
||||
positions: Record<string, IconPos>,
|
||||
col: number,
|
||||
row: number,
|
||||
exceptId: string
|
||||
): IconPos {
|
||||
if (!occupied(positions, col, row, exceptId)) return { col: Math.max(0, col), row: Math.max(0, row) }
|
||||
for (let radius = 1; radius < 64; radius++) {
|
||||
for (let dc = -radius; dc <= radius; dc++) {
|
||||
for (let dr = -radius; dr <= radius; dr++) {
|
||||
if (Math.max(Math.abs(dc), Math.abs(dr)) !== radius) continue
|
||||
const c = col + dc
|
||||
const r = row + dr
|
||||
if (c < 0 || r < 0) continue
|
||||
if (!occupied(positions, c, r, exceptId)) return { col: c, row: r }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { col: Math.max(0, col), row: Math.max(0, row) }
|
||||
}
|
||||
|
||||
export function placeIcon(appId: string, col: number, row: number): void {
|
||||
iconPositions.update((positions) => {
|
||||
const target = nearestFreeCell(positions, col, row, appId)
|
||||
return { ...positions, [appId]: target }
|
||||
})
|
||||
}
|
||||
|
||||
export function iconPixelPos(pos: IconPos): { x: number; y: number } {
|
||||
return {
|
||||
x: GRID.padding + pos.col * (GRID.cell + GRID.gap),
|
||||
y: GRID.padding + pos.row * (GRID.cell + GRID.gap)
|
||||
}
|
||||
}
|
||||
|
||||
export function maxCols(viewportWidth: number): number {
|
||||
return Math.max(1, Math.floor((viewportWidth - GRID.padding) / (GRID.cell + GRID.gap)))
|
||||
}
|
||||
|
||||
export function getIconPositions(): Record<string, IconPos> {
|
||||
return get(iconPositions)
|
||||
}
|
||||
|
||||
// Bails a messy manual layout back to the classic left-edge column,
|
||||
// registry order — the desktop's right-click menu's "Reset icon layout".
|
||||
export function resetIconLayout(): void {
|
||||
iconPositions.set(defaultPositions())
|
||||
}
|
||||
@@ -1,14 +1,83 @@
|
||||
// One global wmkit window manager for the whole app (mounted once by
|
||||
// EntityDesktop.svelte in App.svelte) — this is what lets an entity opened
|
||||
// from Knowledge Base, chat, or anywhere else land in the same floating
|
||||
// window layer, with several windows open side by side, rather than each
|
||||
// page owning its own single-entity sidebar/sheet.
|
||||
// WindowLayer.svelte inside Desktop.svelte) — this is what lets an entity
|
||||
// opened from Knowledge Base, chat, or anywhere else land in the same
|
||||
// floating window layer, with several windows open side by side, rather than
|
||||
// each page owning its own single-entity sidebar/sheet.
|
||||
import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
|
||||
import { persist } from '@surdeddd/wmkit/persist'
|
||||
import { appById, appWindowId } from '$lib/apps'
|
||||
import { sessions } from '$lib/stores/chat'
|
||||
import { heading } from '$lib/tasks'
|
||||
|
||||
export const wm = createManager({ defaultSize: { width: 480, height: 560 } })
|
||||
export const dk = createDesktop(wm)
|
||||
export const dk = createDesktop(wm, {
|
||||
// topEdge:'maximize' + preview gives the classic drag-to-top-maximizes
|
||||
// affordance; magnetism/keyboard are wmkit defaults worth turning on now
|
||||
// that windows are the whole app's primary surface, not a secondary layer.
|
||||
snap: { topEdge: 'maximize', preview: true },
|
||||
keyboard: true,
|
||||
magnetism: true,
|
||||
// Animates a minimized window toward its taskbar button instead of just
|
||||
// vanishing — Taskbar.svelte tags each button with this same attribute.
|
||||
minimizeTarget: (win) => document.querySelector(`[data-taskbar-btn="${CSS.escape(win.id)}"]`)
|
||||
})
|
||||
export const wmState = wmStore(wm)
|
||||
|
||||
// Layout survives reloads: every window id is self-describing (app:<id>,
|
||||
// session:<id>, or a bare entity slug — see EntityDesktop/WindowLayer's
|
||||
// content branch), so a hydrated window needs no extra bookkeeping to know
|
||||
// what to render once the desktop remounts.
|
||||
export const wmPersist = persist(wm, { key: 'oikos-windows', debounce: 300, autoRestore: true })
|
||||
|
||||
// A task window is titled from the operator's (truncated) prompt at
|
||||
// creation time — openTaskWindow below and chat.ts's startTask both only
|
||||
// know the raw text, not the goal/heading the backend eventually derives
|
||||
// for the session. Whenever the sessions list refreshes (loadSessions(),
|
||||
// called all over — on task events, after a turn completes, ...) resync
|
||||
// any open task window's title to the session's real heading, so the
|
||||
// taskbar/titlebar stop showing the placeholder forever.
|
||||
sessions.subscribe((list) => {
|
||||
for (const s of list) {
|
||||
const id = `session:${s.id}`
|
||||
const win = wm.get(id)
|
||||
if (!win) continue
|
||||
const title = heading(s)
|
||||
if (win.title !== title) wm.update(id, { title })
|
||||
}
|
||||
})
|
||||
|
||||
// Classic show-desktop toggle: minimize everything, or if everything's
|
||||
// already minimized (a prior show-desktop, or the operator minimized them
|
||||
// all by hand), bring them all back rather than being a one-way action.
|
||||
// Shared by the taskbar button and the desktop's right-click menu.
|
||||
export function toggleShowDesktop(): void {
|
||||
const anyVisible = wm.getState().order.some((id) => wm.get(id)?.stage !== 'minimized')
|
||||
if (anyVisible) wm.minimizeAll()
|
||||
else wm.restoreAll()
|
||||
}
|
||||
|
||||
// Opens (or focuses/restores) a registry app's window. Apps are
|
||||
// single-instance — double-clicking an already-open app's icon should never
|
||||
// stack a second window, same dedupe pattern as openEntityWindow below.
|
||||
export function openAppWindow(appId: string): void {
|
||||
const app = appById.get(appId)
|
||||
if (!app) return
|
||||
const id = appWindowId(appId)
|
||||
if (wm.get(id)) {
|
||||
wm.restore(id)
|
||||
wm.focus(id)
|
||||
return
|
||||
}
|
||||
wm.open({
|
||||
id,
|
||||
title: app.title,
|
||||
width: app.width,
|
||||
height: app.height,
|
||||
minWidth: app.minWidth,
|
||||
minHeight: app.minHeight
|
||||
})
|
||||
}
|
||||
|
||||
// Opens a window for the entity, or focuses (and restores, if minimized) the
|
||||
// existing one — wm.open() throws if a window with this id already exists,
|
||||
// and slugs make natural, stable window ids (also dedupes "same entity
|
||||
@@ -23,12 +92,28 @@ export function openEntityWindow(slug: string | null): void {
|
||||
wm.open({ id: slug, title: slug })
|
||||
}
|
||||
|
||||
// Singleton "compose a new task" window — the Tasks app's New Task button
|
||||
// opens this rather than a dialog, since everything else in the desktop is
|
||||
// already a window; TaskLauncher closes it itself (via its onStarted
|
||||
// callback, wired up in WindowLayer.svelte) once the task's session window
|
||||
// takes over.
|
||||
export const NEW_TASK_WINDOW_ID = 'new-task'
|
||||
|
||||
export function openNewTaskWindow(): void {
|
||||
if (wm.get(NEW_TASK_WINDOW_ID)) {
|
||||
wm.restore(NEW_TASK_WINDOW_ID)
|
||||
wm.focus(NEW_TASK_WINDOW_ID)
|
||||
return
|
||||
}
|
||||
wm.open({ id: NEW_TASK_WINDOW_ID, title: 'New task', width: 480, height: 340, minWidth: 360, minHeight: 280 })
|
||||
}
|
||||
|
||||
// Same dedupe/restore/focus pattern as openEntityWindow, for a task/session's
|
||||
// chat window. Id is namespaced `session:<id>` — distinct from entity window
|
||||
// ids (always a bare `type:identifier` slug, and task ENTITIES already use
|
||||
// `task:<uuid>` as their own slug) so a task's chat window and its entity
|
||||
// detail window never collide over the same wmkit id. See
|
||||
// EntityDesktop.svelte for the id -> content-component branch.
|
||||
// WindowLayer.svelte for the id -> content-component branch.
|
||||
export function openTaskWindow(sessionId: string | null, title: string): void {
|
||||
if (!sessionId) return
|
||||
const id = `session:${sessionId}`
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { messages, streaming, connectionState, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } from '$lib/stores/chat'
|
||||
import ChatThread from '$lib/components/ChatThread.svelte'
|
||||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||
|
||||
let { showRail = true }: { showRail?: boolean } = $props()
|
||||
|
||||
// Resizable right rail (session graph). Persisted so it survives reloads.
|
||||
const RAIL_MIN = 260
|
||||
const RAIL_MAX = 620
|
||||
function loadRailWidth(): number {
|
||||
if (typeof localStorage === 'undefined') return 320
|
||||
const v = Number(localStorage.getItem('oikos-rail-width'))
|
||||
return v >= RAIL_MIN && v <= RAIL_MAX ? v : 320
|
||||
}
|
||||
let railWidth = $state(loadRailWidth())
|
||||
let resizing = $state(false)
|
||||
|
||||
function startResize(e: PointerEvent) {
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
const startX = e.clientX
|
||||
const startW = railWidth
|
||||
function move(ev: PointerEvent) {
|
||||
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
|
||||
}
|
||||
function up() {
|
||||
resizing = false
|
||||
localStorage.setItem('oikos-rail-width', String(railWidth))
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
}
|
||||
|
||||
const suggestions = [
|
||||
'What needs my attention right now?',
|
||||
'Summarize fleet health',
|
||||
'Any pending approvals or open signals?',
|
||||
'What changed in the last hour?'
|
||||
]
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0">
|
||||
<ChatThread
|
||||
messages={$messages}
|
||||
streaming={$streaming}
|
||||
connectionState={$connectionState}
|
||||
error={$error}
|
||||
chatErrors={$chatErrors}
|
||||
onSend={sendMessage}
|
||||
onCancel={cancelStream}
|
||||
onReconnect={reconnect}
|
||||
onDismissError={dismissError}
|
||||
{suggestions}
|
||||
/>
|
||||
|
||||
{#if showRail}
|
||||
<div class="hidden shrink-0 xl:flex" style="width: {railWidth}px">
|
||||
<button
|
||||
type="button"
|
||||
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
|
||||
onpointerdown={startResize}
|
||||
aria-label="Resize task panel"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
|
||||
? 'bg-primary/60'
|
||||
: 'bg-border group-hover/rz:bg-primary/50'}"
|
||||
></span>
|
||||
</button>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<TaskContextPanel />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,8 +0,0 @@
|
||||
<script lang="ts">
|
||||
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
|
||||
import { openEntityWindow } from '$lib/stores/windows'
|
||||
|
||||
let { slug }: { slug: string } = $props()
|
||||
</script>
|
||||
|
||||
<EntityDetailContent {slug} onSelectEntity={openEntityWindow} />
|
||||
@@ -28,7 +28,7 @@
|
||||
// both views are asking the same underlying question ("show me X").
|
||||
let search = $state('')
|
||||
// Tracks only the most recently opened entity, for row/node highlight —
|
||||
// actual detail viewing now happens in floating windows (EntityDesktop),
|
||||
// actual detail viewing now happens in floating windows (WindowLayer, mounted inside Desktop.svelte),
|
||||
// which can have several entities open at once.
|
||||
let lastOpened = $state<string | null>(null)
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
openEntityWindow(slug)
|
||||
}
|
||||
|
||||
// Drop the highlight once its window is closed (from EntityDesktop, not
|
||||
// Drop the highlight once its window is closed (from WindowLayer, not
|
||||
// necessarily from here) rather than leaving a row/node marked "open" when
|
||||
// it isn't anymore.
|
||||
$effect(() => {
|
||||
@@ -273,7 +273,7 @@
|
||||
</div>
|
||||
|
||||
<!-- browse pane — selecting an entity opens it in a floating window
|
||||
(EntityDesktop, mounted globally in App.svelte) instead of a sidebar. -->
|
||||
(WindowLayer, mounted globally inside Desktop.svelte) instead of a sidebar. -->
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{#if view === 'graph'}
|
||||
<EntityGraph
|
||||
|
||||
@@ -1,47 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fetchDashboardSummary, type DashboardSummary, type Session } from '$lib/api'
|
||||
import { sessions, loadSessions, newChat, sendMessage } from '$lib/stores/chat'
|
||||
import { openTaskWindow } from '$lib/stores/windows'
|
||||
import { sessions, loadSessions } from '$lib/stores/chat'
|
||||
import { openTaskWindow, openNewTaskWindow } from '$lib/stores/windows'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import { bucket, statusStyle, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import GraphBackground from '$lib/components/GraphBackground.svelte'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'
|
||||
|
||||
// ── Dashboard metrics (ported from the old Overview cards) ──────────────
|
||||
let summary = $state<DashboardSummary | null>(null)
|
||||
|
||||
async function loadSummary() {
|
||||
summary = await fetchDashboardSummary()
|
||||
}
|
||||
|
||||
const totalEntities = $derived(
|
||||
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
|
||||
)
|
||||
const entityTypeCount = $derived(summary ? Object.keys(summary.entities_by_type).length : 0)
|
||||
const totalMonitored = $derived(
|
||||
summary
|
||||
? summary.health.healthy + summary.health.degraded + summary.health.down + summary.health.unknown
|
||||
: 0
|
||||
)
|
||||
const healthTone = $derived(
|
||||
!summary ? 'ok' : summary.health.down > 0 ? 'down' : summary.health.degraded > 0 ? 'degraded' : 'ok'
|
||||
)
|
||||
const totalSignals = $derived(
|
||||
summary ? Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0) : 0
|
||||
)
|
||||
const worstSeverity = $derived(
|
||||
summary?.signals_by_severity.critical
|
||||
? 'critical'
|
||||
: summary?.signals_by_severity.warning
|
||||
? 'warning'
|
||||
: 'none'
|
||||
)
|
||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||
import type { Session } from '$lib/api'
|
||||
|
||||
// ── Task board ──────────────────────────────────────────────────────────
|
||||
let filter = $state<'all' | Bucket>('all')
|
||||
@@ -59,28 +26,9 @@
|
||||
openTaskWindow(s.id, heading(s))
|
||||
}
|
||||
|
||||
// ── New task entry ──────────────────────────────────────────────────────
|
||||
let input = $state('')
|
||||
function submit() {
|
||||
const text = input.trim()
|
||||
if (!text) return
|
||||
input = ''
|
||||
newChat()
|
||||
sendMessage(text)
|
||||
location.hash = '#/chat'
|
||||
}
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadSummary()
|
||||
loadSessions()
|
||||
const unsubStream = subscribeEvents()
|
||||
const summaryTimer = setInterval(loadSummary, 15000)
|
||||
|
||||
// Refetch the board when a task's lifecycle changes anywhere. Scan all
|
||||
// events newer than the last seen (entity.touched fires constantly and
|
||||
@@ -102,161 +50,78 @@
|
||||
return () => {
|
||||
unsub()
|
||||
unsubStream()
|
||||
clearInterval(summaryTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="relative h-full overflow-hidden">
|
||||
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-4">
|
||||
<GraphBackground />
|
||||
|
||||
<div class="relative z-10 h-full overflow-y-auto">
|
||||
<!-- Hero: fills the viewport. The input is pinned to true vertical center via the
|
||||
grid's middle 1fr row; the metrics strip and scroll hint sit in the auto rows
|
||||
above/below without shifting it off-center. Scrolling lifts the whole hero to
|
||||
reveal the table. -->
|
||||
<section class="grid min-h-full grid-rows-[auto_1fr_auto] gap-6 px-4 py-16">
|
||||
<!-- Metrics strip -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 text-xs">
|
||||
<div class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur">
|
||||
<span class="text-muted-foreground">Entities</span>
|
||||
<span class="font-semibold tabular-nums">{totalEntities}</span>
|
||||
{#if entityTypeCount}<span class="text-muted-foreground">· {entityTypeCount} types</span>{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur">
|
||||
<span
|
||||
class="size-2 rounded-full {healthTone === 'ok' ? 'bg-success' : healthTone === 'degraded' ? 'bg-warning' : 'bg-destructive'}"
|
||||
></span>
|
||||
<span class="text-muted-foreground">Health</span>
|
||||
<span class="font-semibold tabular-nums">{summary?.health.healthy ?? 0} / {totalMonitored}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (location.hash = '#/signals')}
|
||||
class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur transition-colors hover:border-primary/50"
|
||||
>
|
||||
{#if worstSeverity === 'critical'}
|
||||
<TriangleAlertIcon class="size-3.5 text-destructive" />
|
||||
{:else if worstSeverity === 'warning'}
|
||||
<TriangleAlertIcon class="size-3.5 text-warning" />
|
||||
{:else}
|
||||
<CircleCheckIcon class="size-3.5 text-success" />
|
||||
{/if}
|
||||
<span class="text-muted-foreground">Signals</span>
|
||||
<span class="font-semibold tabular-nums">{totalSignals}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (location.hash = '#/ops')}
|
||||
class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur transition-colors hover:border-primary/50"
|
||||
>
|
||||
<span class="text-muted-foreground">Approvals</span>
|
||||
<span class="font-semibold tabular-nums {summary?.approvals_pending ? 'text-destructive' : ''}"
|
||||
>{summary?.approvals_pending ?? 0}</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative z-10 flex flex-wrap items-center gap-1.5">
|
||||
{#each FILTERS as f}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (filter = f.id)}
|
||||
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-card/60 text-muted-foreground backdrop-blur hover:bg-muted/50'}"
|
||||
>
|
||||
{f.label}
|
||||
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="flex-1"></div>
|
||||
<Button size="sm" class="gap-1.5" onclick={openNewTaskWindow}>
|
||||
<PlusIcon class="size-4" />
|
||||
New task
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- New task entry: centered in the middle (1fr) row -->
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-full max-w-2xl text-center">
|
||||
<h1 class="mb-1 text-2xl font-semibold tracking-tight">What should Nomos do?</h1>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Describe a goal — Nomos will plan it, execute it, and report the outcome.
|
||||
<div class="relative z-10 min-h-0 flex-1 overflow-auto rounded-xl border bg-card/70 backdrop-blur">
|
||||
{#if visible.length === 0}
|
||||
<div class="flex flex-col items-center gap-2 px-4 py-16 text-center">
|
||||
<p class="max-w-sm text-sm text-muted-foreground">
|
||||
{filter === 'all'
|
||||
? 'No tasks yet. Start one and Nomos will plan it, execute it, and report the outcome.'
|
||||
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
|
||||
</p>
|
||||
<form
|
||||
class="relative rounded-2xl border bg-card/70 shadow-lg backdrop-blur focus-within:border-primary/60"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
bind:value={input}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="e.g. Roll the staging database back to last night's snapshot and verify the app is healthy…"
|
||||
rows={3}
|
||||
class="max-h-52 min-h-24 resize-none border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
<div class="flex items-center justify-between px-3 pb-3">
|
||||
<span class="text-[11px] text-muted-foreground">Enter to start · Shift+Enter for newline</span>
|
||||
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Start task">
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="justify-self-center text-[11px] text-muted-foreground/70">Scroll to see all tasks ↓</span>
|
||||
</section>
|
||||
|
||||
<!-- Task table -->
|
||||
<section class="mx-auto w-full max-w-5xl px-4 pb-16">
|
||||
<div class="rounded-xl border bg-card/70 backdrop-blur">
|
||||
<div class="flex flex-wrap items-center gap-1.5 border-b p-3">
|
||||
{#each FILTERS as f}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (filter = f.id)}
|
||||
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-muted/50'}"
|
||||
{:else}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-muted-foreground">
|
||||
<th class="w-36 px-4 py-2 font-medium">Status</th>
|
||||
<th class="px-4 py-2 font-medium">Task</th>
|
||||
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>
|
||||
<th class="w-28 px-4 py-2 text-right font-medium">Last active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each visible as s (s.id)}
|
||||
{@const st = statusStyle(s)}
|
||||
<tr
|
||||
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
|
||||
onclick={() => openTask(s)}
|
||||
>
|
||||
{f.label}
|
||||
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
|
||||
</button>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
|
||||
{st.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="max-w-0 px-4 py-2.5">
|
||||
<span class="line-clamp-1 font-medium">{heading(s)}</span>
|
||||
</td>
|
||||
<td class="hidden max-w-0 px-4 py-2.5 md:table-cell">
|
||||
<span class="line-clamp-1 text-xs text-muted-foreground">{s.summary || '—'}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-2.5 text-right text-[11px] text-muted-foreground">
|
||||
{relativeTime(s.last_active_at)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if visible.length === 0}
|
||||
<div class="flex flex-col items-center gap-2 px-4 py-16 text-center">
|
||||
<p class="max-w-sm text-sm text-muted-foreground">
|
||||
{filter === 'all'
|
||||
? 'No tasks yet. Start one above and Nomos will plan it, execute it, and report the outcome.'
|
||||
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-muted-foreground">
|
||||
<th class="w-36 px-4 py-2 font-medium">Status</th>
|
||||
<th class="px-4 py-2 font-medium">Task</th>
|
||||
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>
|
||||
<th class="w-28 px-4 py-2 text-right font-medium">Last active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each visible as s (s.id)}
|
||||
{@const st = statusStyle(s)}
|
||||
<tr
|
||||
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
|
||||
onclick={() => openTask(s)}
|
||||
>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
|
||||
{st.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="max-w-0 px-4 py-2.5">
|
||||
<span class="line-clamp-1 font-medium">{heading(s)}</span>
|
||||
</td>
|
||||
<td class="hidden max-w-0 px-4 py-2.5 md:table-cell">
|
||||
<span class="line-clamp-1 text-xs text-muted-foreground">{s.summary || '—'}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-2.5 text-right text-[11px] text-muted-foreground">
|
||||
{relativeTime(s.last_active_at)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
17
web/src/test-setup.ts
Normal file
17
web/src/test-setup.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// jsdom doesn't implement matchMedia — anything that transitively imports
|
||||
// theme.svelte.ts (which reads the OS color-scheme preference at module
|
||||
// load) throws without this. Global vitest setup so every test file gets it
|
||||
// for free instead of each one needing its own mock.
|
||||
if (typeof window !== 'undefined' && !window.matchMedia) {
|
||||
window.matchMedia = (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false
|
||||
}) as MediaQueryList
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{ts,js}']
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user