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