diff --git a/web/src/app.css b/web/src/app.css index e3627b7..4756740 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -254,7 +254,12 @@ flex-direction: column; box-sizing: border-box; pointer-events: auto; - background: var(--card); + /* Frosted glass — same recipe as the desktop's "What should Nomos do?" + launcher card (bg-card/70 backdrop-blur), so floating windows read as + part of the same surface language instead of opaque panels. */ + background: color-mix(in oklab, var(--card) 70%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); color: var(--card-foreground); border: 1px solid var(--border); border-radius: var(--radius-lg); diff --git a/web/src/lib/components/ChatThread.svelte b/web/src/lib/components/ChatThread.svelte index 0ecdb85..142a790 100644 --- a/web/src/lib/components/ChatThread.svelte +++ b/web/src/lib/components/ChatThread.svelte @@ -100,13 +100,15 @@ }) const inputMinSize = $derived(threadHeight > 0 ? (oneLinePx / threadHeight) * 100 : 12) + // Keep the input pinned to inputMinSize (one line) until the user actually + // drags the splitter — not just on the first measurement. A floating + // window's threadHeight is 0/wrong for a frame or two while it animates + // open, and locking the percentage to that first reading left the input + // several lines tall once the window reached full size (fixed 2026-07-21). let inputSize = $state(12) - let inputSizeDefaulted = false + let userResizedInput = false $effect(() => { - if (!inputSizeDefaulted && threadHeight > 0) { - inputSize = inputMinSize - inputSizeDefaulted = true - } + if (!userResizedInput) inputSize = inputMinSize }) function isNearBottom(): boolean { @@ -172,12 +174,12 @@
- + (userResizedInput = true)}>
-
+
{#if messages.length === 0} -
+

Nomos

Your resident operator. Ask about the fleet, or tell it to act.

diff --git a/web/src/lib/components/SessionChatWindow.svelte b/web/src/lib/components/SessionChatWindow.svelte index 143ccc2..b8bfb6d 100644 --- a/web/src/lib/components/SessionChatWindow.svelte +++ b/web/src/lib/components/SessionChatWindow.svelte @@ -7,6 +7,7 @@ import { Pane, Splitpanes } from 'svelte-splitpanes' import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat' import { activityLogFor } from '$lib/stores/activity' + import { workspaceFor, startSessionWorkspace } from '$lib/stores/workspace' import ChatThread from '$lib/components/ChatThread.svelte' import TaskContextPanel from '$lib/components/TaskContextPanel.svelte' @@ -26,14 +27,46 @@ const chatNotFound = chat.notFound // eslint-disable-next-line svelte/valid-compile const sessionActivityLog = activityLogFor(sessionId) + // Started here (rather than left to TaskContextPanel's own onMount) so the + // workspace is already tracking touched entities/plan/questions before the + // context rail ever mounts — it needs that live even while the rail stays + // hidden (see hasContext below). + // eslint-disable-next-line svelte/valid-compile + const workspace = workspaceFor(sessionId) + // eslint-disable-next-line svelte/valid-compile + const touchedEntities = workspace.touched + // eslint-disable-next-line svelte/valid-compile + const openQuestion = workspace.openQuestion let loading = $state(true) + // The context rail (Scope/Activity) is only worth its screen space once + // there's something in it — a brand-new task otherwise opens to an empty + // "entities appear here" placeholder next to an equally empty activity + // list. Show it the moment any of the three has real content, and keep it + // shown from then on (no flicker back to hidden if e.g. touched entities + // later expire). + let hasContext = $state(false) + $effect(() => { + if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0 || $openQuestion !== null)) { + hasContext = true + } + }) + + // startSessionWorkspace's cleanup is registered via onDestroy below rather + // than returned from this callback — onMount ignores a returned function + // once the callback is async (its return value is a Promise, not the + // cleanup itself). + const stopWorkspace = startSessionWorkspace(sessionId) + onMount(async () => { await loadSessionChat(sessionId) loading = false }) - onDestroy(() => stopSessionPolling(sessionId)) + onDestroy(() => { + stopSessionPolling(sessionId) + stopWorkspace() + }) // Resizable right rail — sized smaller by default since task windows open // narrower than the full page. @@ -48,7 +81,7 @@

Task not found.

It may have been deleted.

- {:else} + {:else if hasContext} + {:else} + sendSessionMessage(sessionId, text)} + onCancel={() => cancelSessionStream(sessionId)} + onReconnect={() => loadSessionChat(sessionId)} + onDismissError={dismissError} + /> {/if}
diff --git a/web/src/lib/components/TaskContextPanel.svelte b/web/src/lib/components/TaskContextPanel.svelte index 9fc1d27..72faece 100644 --- a/web/src/lib/components/TaskContextPanel.svelte +++ b/web/src/lib/components/TaskContextPanel.svelte @@ -1,7 +1,7 @@ + + {}} + onReconnect={() => {}} + onDismissError={() => {}} +/> diff --git a/web/src/lib/components/desktop-shell/WindowLayer.svelte b/web/src/lib/components/desktop-shell/WindowLayer.svelte index cb611ce..650508b 100644 --- a/web/src/lib/components/desktop-shell/WindowLayer.svelte +++ b/web/src/lib/components/desktop-shell/WindowLayer.svelte @@ -7,13 +7,13 @@ // wmPersist in windows.ts) need no extra bookkeeping to know what to render: // app: -> registry component (windows.ts openAppWindow) // session: -> SessionChatWindow (windows.ts openTaskWindow) - // new-task -> TaskLauncher (windows.ts openNewTaskWindow) + // new-task -> NewTaskChat (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 NewTaskChat from './NewTaskChat.svelte' import XIcon from '@lucide/svelte/icons/x' import MinusIcon from '@lucide/svelte/icons/minus' import Maximize2Icon from '@lucide/svelte/icons/maximize-2' @@ -73,9 +73,7 @@ {#if id.startsWith(SESSION_PREFIX)} {:else if id === NEW_TASK_WINDOW_ID} -
- wm.close(NEW_TASK_WINDOW_ID)} /> -
+ {:else if app} {:else} diff --git a/web/src/lib/stores/windows.ts b/web/src/lib/stores/windows.ts index 6c28c24..149eafa 100644 --- a/web/src/lib/stores/windows.ts +++ b/web/src/lib/stores/windows.ts @@ -94,9 +94,10 @@ export function openEntityWindow(slug: string | null): void { // 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. +// already a window. It renders as an empty chat (NewTaskChat, in +// WindowLayer.svelte) sized like a real task window rather than a separate +// compose screen, and closes itself once the task's session window takes +// over. export const NEW_TASK_WINDOW_ID = 'new-task' export function openNewTaskWindow(): void { @@ -105,7 +106,7 @@ export function openNewTaskWindow(): void { 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 }) + wm.open({ id: NEW_TASK_WINDOW_ID, title: 'New task', width: 900, height: 640, minWidth: 600, minHeight: 400 }) } // Same dedupe/restore/focus pattern as openEntityWindow, for a task/session's