feat(web): new task opens straight into chat, context rail waits for content, frosted windows
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

New Task now opens directly as an empty ChatThread (NewTaskChat) instead of
a separate compose screen, sized like a real task window. The Scope/Activity
context rail in a task window no longer renders until there's actually
something to show (touched entities, activity, or an open question),
avoiding an empty-placeholder sidebar on every new task. Also fixes the
chat input defaulting to several lines tall on window open, centers the
empty-chat greeting vertically, and gives floating windows the same
frosted-glass look as the desktop's task launcher card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 09:50:47 +02:00
parent ce34cfeac7
commit 6b6bfe1fd8
7 changed files with 107 additions and 23 deletions

View File

@@ -254,7 +254,12 @@
flex-direction: column; flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
pointer-events: auto; 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); color: var(--card-foreground);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);

View File

@@ -100,13 +100,15 @@
}) })
const inputMinSize = $derived(threadHeight > 0 ? (oneLinePx / threadHeight) * 100 : 12) 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 inputSize = $state(12)
let inputSizeDefaulted = false let userResizedInput = false
$effect(() => { $effect(() => {
if (!inputSizeDefaulted && threadHeight > 0) { if (!userResizedInput) inputSize = inputMinSize
inputSize = inputMinSize
inputSizeDefaulted = true
}
}) })
function isNearBottom(): boolean { function isNearBottom(): boolean {
@@ -172,12 +174,12 @@
</script> </script>
<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}> <div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1"> <Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1" on:resize={() => (userResizedInput = true)}>
<Pane class="flex flex-col"> <Pane class="flex flex-col">
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}> <div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4"> <div class="mx-auto flex min-h-full max-w-3xl flex-col gap-5 p-4">
{#if messages.length === 0} {#if messages.length === 0}
<div class="flex flex-col items-center gap-6 pt-24 text-center"> <div class="flex flex-1 flex-col items-center justify-center gap-6 text-center">
<div> <div>
<h2 class="text-xl font-semibold">Nomos</h2> <h2 class="text-xl font-semibold">Nomos</h2>
<p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p> <p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p>

View File

@@ -7,6 +7,7 @@
import { Pane, Splitpanes } from 'svelte-splitpanes' import { Pane, Splitpanes } from 'svelte-splitpanes'
import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat' import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat'
import { activityLogFor } from '$lib/stores/activity' import { activityLogFor } from '$lib/stores/activity'
import { workspaceFor, startSessionWorkspace } from '$lib/stores/workspace'
import ChatThread from '$lib/components/ChatThread.svelte' import ChatThread from '$lib/components/ChatThread.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte' import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
@@ -26,14 +27,46 @@
const chatNotFound = chat.notFound const chatNotFound = chat.notFound
// eslint-disable-next-line svelte/valid-compile // eslint-disable-next-line svelte/valid-compile
const sessionActivityLog = activityLogFor(sessionId) 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) 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 () => { onMount(async () => {
await loadSessionChat(sessionId) await loadSessionChat(sessionId)
loading = false loading = false
}) })
onDestroy(() => stopSessionPolling(sessionId)) onDestroy(() => {
stopSessionPolling(sessionId)
stopWorkspace()
})
// Resizable right rail — sized smaller by default since task windows open // Resizable right rail — sized smaller by default since task windows open
// narrower than the full page. // narrower than the full page.
@@ -48,7 +81,7 @@
<p class="text-sm text-muted-foreground">Task not found.</p> <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> <p class="text-xs text-muted-foreground/70">It may have been deleted.</p>
</div> </div>
{:else} {:else if hasContext}
<Splitpanes theme="oikos-theme" dblClickSplitter={false}> <Splitpanes theme="oikos-theme" dblClickSplitter={false}>
<Pane> <Pane>
<ChatThread <ChatThread
@@ -68,5 +101,18 @@
<TaskContextPanel {sessionId} /> <TaskContextPanel {sessionId} />
</Pane> </Pane>
</Splitpanes> </Splitpanes>
{:else}
<ChatThread
messages={$chatMessages}
streaming={$chatStreaming}
connectionState={$chatConnectionState}
error={$chatError}
chatErrors={$chatErrors}
activityLog={sessionActivityLog}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
onDismissError={dismissError}
/>
{/if} {/if}
</div> </div>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes' import { Pane, Splitpanes } from 'svelte-splitpanes'
import { startWorkspace, startSessionWorkspace, planSteps, currentTask, openQuestion, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace' import { startWorkspace, planSteps, currentTask, openQuestion, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
import { streaming, messages, currentSession, chatFor } from '$lib/stores/chat' import { streaming, messages, currentSession, chatFor } from '$lib/stores/chat'
import { activityLog, activityLogFor } from '$lib/stores/activity' import { activityLog, activityLogFor } from '$lib/stores/activity'
import OperatorQuestion from './OperatorQuestion.svelte' import OperatorQuestion from './OperatorQuestion.svelte'
@@ -16,10 +16,13 @@
// SessionChatWindow): this panel switches entirely to that session's own // SessionChatWindow): this panel switches entirely to that session's own
// store bundle (workspaceFor/chatFor/activityLogFor), so several windows' // store bundle (workspaceFor/chatFor/activityLogFor), so several windows'
// panels can be open and live at once instead of all showing whatever // panels can be open and live at once instead of all showing whatever
// happens to be the single global "current session". // happens to be the single global "current session". Per-session workspace
// tracking (startSessionWorkspace) is started by SessionChatWindow itself,
// not here — it has to run even while this panel stays unmounted (see its
// hasContext gate), so only the global fallback path starts its own here.
let { sessionId = null }: { sessionId?: string | null } = $props() let { sessionId = null }: { sessionId?: string | null } = $props()
onMount(() => (sessionId ? startSessionWorkspace(sessionId) : startWorkspace())) onMount(() => (sessionId ? undefined : startWorkspace()))
const ws = $derived(sessionId ? workspaceFor(sessionId) : null) const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
const planStepsStore = $derived(ws ? ws.planSteps : planSteps) const planStepsStore = $derived(ws ? ws.planSteps : planSteps)

View File

@@ -0,0 +1,29 @@
<script lang="ts">
// Content for the "new-task" window slot (windows.ts openNewTaskWindow) —
// a real ChatThread in its empty state rather than a separate compose
// screen, so starting a task looks and feels exactly like the task chat
// it becomes. Submitting the first message starts the task via
// startTask() and hands off to the real session window (see windows.ts's
// openTaskWindow) the moment the backend assigns an id.
import { startTask } from '$lib/stores/chat'
import { openTaskWindow, wm, NEW_TASK_WINDOW_ID } from '$lib/stores/windows'
import { truncateMiddle } from '$lib/utils'
import ChatThread from '$lib/components/ChatThread.svelte'
function onSend(text: string) {
startTask(text, (sessionId) => {
openTaskWindow(sessionId, truncateMiddle(text, 60))
wm.close(NEW_TASK_WINDOW_ID)
})
}
</script>
<ChatThread
messages={[]}
streaming={false}
connectionState="connected"
onSend={onSend}
onCancel={() => {}}
onReconnect={() => {}}
onDismissError={() => {}}
/>

View File

@@ -7,13 +7,13 @@
// wmPersist in windows.ts) need no extra bookkeeping to know what to render: // wmPersist in windows.ts) need no extra bookkeeping to know what to render:
// app:<id> -> registry component (windows.ts openAppWindow) // app:<id> -> registry component (windows.ts openAppWindow)
// session:<id> -> SessionChatWindow (windows.ts openTaskWindow) // session:<id> -> SessionChatWindow (windows.ts openTaskWindow)
// new-task -> TaskLauncher (windows.ts openNewTaskWindow) // new-task -> NewTaskChat (windows.ts openNewTaskWindow)
// anything else -> entity slug -> EntityDetailContent // anything else -> entity slug -> EntityDetailContent
import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID } from '$lib/stores/windows' import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID } from '$lib/stores/windows'
import { appById, appIdFromWindowId } from '$lib/apps' import { appById, appIdFromWindowId } from '$lib/apps'
import EntityDetailContent from '../EntityDetailContent.svelte' import EntityDetailContent from '../EntityDetailContent.svelte'
import SessionChatWindow from '../SessionChatWindow.svelte' import SessionChatWindow from '../SessionChatWindow.svelte'
import TaskLauncher from './TaskLauncher.svelte' import NewTaskChat from './NewTaskChat.svelte'
import XIcon from '@lucide/svelte/icons/x' import XIcon from '@lucide/svelte/icons/x'
import MinusIcon from '@lucide/svelte/icons/minus' import MinusIcon from '@lucide/svelte/icons/minus'
import Maximize2Icon from '@lucide/svelte/icons/maximize-2' import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
@@ -73,9 +73,7 @@
{#if id.startsWith(SESSION_PREFIX)} {#if id.startsWith(SESSION_PREFIX)}
<SessionChatWindow sessionId={id.slice(SESSION_PREFIX.length)} /> <SessionChatWindow sessionId={id.slice(SESSION_PREFIX.length)} />
{:else if id === NEW_TASK_WINDOW_ID} {:else if id === NEW_TASK_WINDOW_ID}
<div class="flex h-full items-center justify-center p-6"> <NewTaskChat />
<TaskLauncher onStarted={() => wm.close(NEW_TASK_WINDOW_ID)} />
</div>
{:else if app} {:else if app}
<app.component /> <app.component />
{:else} {:else}

View File

@@ -94,9 +94,10 @@ export function openEntityWindow(slug: string | null): void {
// Singleton "compose a new task" window — the Tasks app's New Task button // 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 // opens this rather than a dialog, since everything else in the desktop is
// already a window; TaskLauncher closes it itself (via its onStarted // already a window. It renders as an empty chat (NewTaskChat, in
// callback, wired up in WindowLayer.svelte) once the task's session window // WindowLayer.svelte) sized like a real task window rather than a separate
// takes over. // compose screen, and closes itself once the task's session window takes
// over.
export const NEW_TASK_WINDOW_ID = 'new-task' export const NEW_TASK_WINDOW_ID = 'new-task'
export function openNewTaskWindow(): void { export function openNewTaskWindow(): void {
@@ -105,7 +106,7 @@ export function openNewTaskWindow(): void {
wm.focus(NEW_TASK_WINDOW_ID) wm.focus(NEW_TASK_WINDOW_ID)
return 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 // Same dedupe/restore/focus pattern as openEntityWindow, for a task/session's