feat(web): new task opens straight into chat, context rail waits for content, frosted windows
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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
29
web/src/lib/components/desktop-shell/NewTaskChat.svelte
Normal file
29
web/src/lib/components/desktop-shell/NewTaskChat.svelte
Normal 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={() => {}}
|
||||
/>
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user