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;
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);

View File

@@ -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 @@
</script>
<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">
<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}
<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>
<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>

View File

@@ -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 @@
<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}
{:else if hasContext}
<Splitpanes theme="oikos-theme" dblClickSplitter={false}>
<Pane>
<ChatThread
@@ -68,5 +101,18 @@
<TaskContextPanel {sessionId} />
</Pane>
</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}
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte'
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 { activityLog, activityLogFor } from '$lib/stores/activity'
import OperatorQuestion from './OperatorQuestion.svelte'
@@ -16,10 +16,13 @@
// SessionChatWindow): this panel switches entirely to that session's own
// store bundle (workspaceFor/chatFor/activityLogFor), so several windows'
// 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()
onMount(() => (sessionId ? startSessionWorkspace(sessionId) : startWorkspace()))
onMount(() => (sessionId ? undefined : startWorkspace()))
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
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:
// app:<id> -> registry component (windows.ts openAppWindow)
// session:<id> -> 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)}
<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>
<NewTaskChat />
{:else if app}
<app.component />
{: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
// 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