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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user