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;
|
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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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:
|
// 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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user