feat(web): redesign task rail — Plan/Event log polish, entity graph resize fix
- Plan/Activity panels: humanize step titles, richer icons, empty states matching Scope's illustration style, pretty-printed expandable detail - Activity log renamed to Event log; every step now expandable - Chat: middle-truncate header title, remove redundant task-list rail and header stat cluster (duplicated in the sidebar), simplify markdown styling - Fix --font-mono actually being a monospace font (was aliased to DM Sans) - Replace rotating loader-circle spinner with a smoother fading-blade Spinner - SessionGraph entity detail panel: resizable and self-clamping against its live container size (was overflowing into sibling sections), close button - Dev launch config: fetch bearer token from the running api container so `npm run dev` works against the local compose stack without a hardcoded secret in a tracked file Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,8 @@
|
|||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "sh",
|
||||||
"runtimeArgs": ["--prefix", "web", "run", "dev"],
|
"runtimeArgs": ["-c", "export OIKOS_API_TOKEN=$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' oikos-api-1 | sed -n 's/^OIKOS_MCP_BEARER_TOKEN=//p'); exec npm --prefix web run dev"],
|
||||||
"port": 5173
|
"port": 5173
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>Oikos</title>
|
<title>Oikos</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Inknut+Antiqua:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Inknut+Antiqua:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
||||||
|
|||||||
@@ -10,15 +10,14 @@
|
|||||||
import Config from './pages/Config.svelte'
|
import Config from './pages/Config.svelte'
|
||||||
import { newChat } from '$lib/stores/chat'
|
import { newChat } from '$lib/stores/chat'
|
||||||
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
||||||
import { connectionState } from '$lib/stores/events'
|
|
||||||
import { currentTask } from '$lib/stores/workspace'
|
import { currentTask } from '$lib/stores/workspace'
|
||||||
import { isConfigured } from '$lib/config'
|
import { isConfigured } from '$lib/config'
|
||||||
|
import { truncateMiddle } from '$lib/utils'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { processPendingCallback, initOIDC } from '$lib/oidc'
|
import { processPendingCallback, initOIDC } from '$lib/oidc'
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar'
|
import * as Sidebar from '$lib/components/ui/sidebar'
|
||||||
import * as Sheet from '$lib/components/ui/sheet'
|
import * as Sheet from '$lib/components/ui/sheet'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
|
||||||
import { Separator } from '$lib/components/ui/separator'
|
import { Separator } from '$lib/components/ui/separator'
|
||||||
import { VERSION } from '$lib/version'
|
import { VERSION } from '$lib/version'
|
||||||
import { Toaster } from '$lib/components/ui/sonner'
|
import { Toaster } from '$lib/components/ui/sonner'
|
||||||
@@ -206,37 +205,15 @@
|
|||||||
<Sidebar.Trigger class="-ms-1" />
|
<Sidebar.Trigger class="-ms-1" />
|
||||||
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
|
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
|
||||||
{#if page === 'chat'}
|
{#if page === 'chat'}
|
||||||
<button type="button" class="text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Tasks</button>
|
{@const goalText = $currentTask?.goal ? $currentTask.goal.replace(/[*_`~#]|\[.*?\]\(.*?\)/g, '') : 'New Task'}
|
||||||
<span class="text-muted-foreground">/</span>
|
<button type="button" class="shrink-0 text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Tasks</button>
|
||||||
<span class="text-base font-medium">
|
<span class="shrink-0 text-muted-foreground">/</span>
|
||||||
{$currentTask?.goal ? $currentTask.goal.replace(/[*_`~#]|\[.*?\]\(.*?\)/g, '') : 'New Task'}
|
<span class="min-w-0 flex-1 truncate text-base font-medium" title={goalText}>
|
||||||
|
{truncateMiddle(goalText, 100)}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page === 'overview' ? 'Tasks' : page}</span>
|
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page === 'overview' ? 'Tasks' : page}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="ms-auto flex items-center gap-2.5">
|
|
||||||
{#if $summary}
|
|
||||||
<div class="hidden items-center gap-2.5 text-xs text-muted-foreground sm:flex">
|
|
||||||
<span class="flex items-center gap-1" title="healthy"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
|
|
||||||
<span class="flex items-center gap-1" title="degraded"><span class="size-2 rounded-full bg-warning"></span>{$summary.health.degraded}</span>
|
|
||||||
<span class="flex items-center gap-1" title="down"><span class="size-2 rounded-full bg-destructive"></span>{$summary.health.down}</span>
|
|
||||||
</div>
|
|
||||||
{#if approvalsPending}
|
|
||||||
<button type="button" onclick={() => navigate('ops')}>
|
|
||||||
<Badge variant="destructive" class="cursor-pointer">{approvalsPending} approval{approvalsPending === 1 ? '' : 's'}</Badge>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if openSignals}
|
|
||||||
<button type="button" onclick={() => navigate('signals')}>
|
|
||||||
<Badge variant="secondary" class="cursor-pointer">{openSignals} signal{openSignals === 1 ? '' : 's'}</Badge>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
<span
|
|
||||||
class="size-2 rounded-full {$connectionState === 'open' ? 'bg-success' : $connectionState === 'connecting' ? 'animate-pulse bg-warning' : 'bg-destructive'}"
|
|
||||||
title="event stream: {$connectionState}"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<main class="min-h-0 flex-1 overflow-hidden">
|
<main class="min-h-0 flex-1 overflow-hidden">
|
||||||
{#if page === 'overview'}
|
{#if page === 'overview'}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: 'DM Sans', system-ui, sans-serif;
|
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||||
--font-mono: 'DM Sans', system-ui, sans-serif;
|
--font-mono: 'DM Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
--font-heading: 'Inknut Antiqua', Georgia, serif;
|
--font-heading: 'Inknut Antiqua', Georgia, serif;
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
|
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
import Spinner from './Spinner.svelte'
|
||||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
|
||||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||||
import TargetIcon from '@lucide/svelte/icons/target'
|
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
||||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
|
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
function typeIcon(type: ActivityEntry['type']) {
|
function typeIcon(type: ActivityEntry['type']) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'goal': return TargetIcon
|
case 'goal': return MilestoneIcon
|
||||||
case 'plan': return ListTodoIcon
|
case 'plan': return ListTodoIcon
|
||||||
case 'step_running': case 'step_done': case 'step_failed':
|
case 'step_running': case 'step_done': case 'step_failed':
|
||||||
case 'tool_running': case 'tool_done': case 'tool_error':
|
case 'tool_running': case 'tool_done': case 'tool_error':
|
||||||
@@ -36,29 +36,55 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusColor(status: ActivityEntry['status']) {
|
function statusColor(status: ActivityEntry['status']) {
|
||||||
if (status === 'running') return 'text-primary'
|
|
||||||
if (status === 'failed') return 'text-destructive'
|
if (status === 'failed') return 'text-destructive'
|
||||||
return 'text-success'
|
return 'text-primary'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool results often arrive as a JSON string — pretty-print it when it
|
||||||
|
// parses, otherwise fall back to the raw text rather than hiding it.
|
||||||
|
function prettyPrint(raw: string): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||||
|
} catch {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: number): string | null {
|
||||||
|
if (!ts) return null
|
||||||
|
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
{#if $activityLog.length === 0}
|
{#if $activityLog.length === 0}
|
||||||
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
|
<div class="flex flex-col items-center gap-3 px-3 py-8 text-center">
|
||||||
<div class="flex items-center gap-0.5">
|
<svg viewBox="0 0 64 110" class="h-20 w-auto text-muted-foreground/40" fill="none">
|
||||||
<div class="size-1 rounded-full bg-muted-foreground/20 animate-pulse" style="animation-delay:0s" />
|
<line x1="32" y1="8" x2="32" y2="102" stroke="currentColor" stroke-width="1" stroke-dasharray="2.5 4" opacity="0.35" />
|
||||||
<div class="size-1 rounded-full bg-muted-foreground/30 animate-pulse" style="animation-delay:0.15s" />
|
<circle cx="32" cy="22" r="4" fill="currentColor">
|
||||||
<div class="size-1 rounded-full bg-muted-foreground/20 animate-pulse" style="animation-delay:0.3s" />
|
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" repeatCount="indefinite" />
|
||||||
</div>
|
</circle>
|
||||||
<p class="text-xs text-muted-foreground">Waiting for activity…</p>
|
<circle cx="32" cy="55" r="4" fill="currentColor">
|
||||||
|
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<animate attributeName="r" values="4;11;4" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
||||||
|
<animate attributeName="opacity" values="0.6;0;0.6" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<circle cx="32" cy="88" r="4" fill="currentColor">
|
||||||
|
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
<p class="max-w-[12rem] text-xs leading-relaxed text-muted-foreground">Waiting for activity…</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col py-1">
|
<div class="flex flex-col py-1">
|
||||||
{#each $activityLog as entry, i (entry.id)}
|
{#each $activityLog as entry, i (entry.id)}
|
||||||
{@const isLast = i === $activityLog.length - 1}
|
{@const isLast = i === $activityLog.length - 1}
|
||||||
{@const hasDetail = !!entry.detail}
|
|
||||||
{@const icon = typeIcon(entry.type)}
|
{@const icon = typeIcon(entry.type)}
|
||||||
|
{@const isOpen = expanded.has(entry.id)}
|
||||||
|
{@const time = formatTime(entry.timestamp)}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<!-- connector line -->
|
<!-- connector line -->
|
||||||
{#if !isLast}
|
{#if !isLast}
|
||||||
@@ -66,39 +92,56 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-start gap-2 {entry.indent ? 'pl-7' : 'px-3'} py-1.5 text-left text-xs hover:bg-muted/30 {hasDetail ? 'cursor-pointer' : 'cursor-default'}"
|
class="flex w-full items-start gap-2 {entry.indent ? 'pl-7' : 'px-3'} py-1.5 text-left text-xs hover:bg-muted/30 cursor-pointer"
|
||||||
onclick={() => hasDetail && toggle(entry.id)}
|
onclick={() => toggle(entry.id)}
|
||||||
>
|
>
|
||||||
<!-- status icon -->
|
<!-- status icon -->
|
||||||
<span class="relative mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full {statusColor(entry.status)}">
|
<span class="relative mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full {statusColor(entry.status)}">
|
||||||
{#if entry.status === 'running'}
|
{#if entry.status === 'running'}
|
||||||
<LoaderCircleIcon class="size-3.5 animate-spin" />
|
<Spinner class="size-3.5" />
|
||||||
{:else if entry.status === 'failed'}
|
{:else if entry.status === 'failed'}
|
||||||
<CircleXIcon class="size-3.5" />
|
<CircleXIcon class="size-3.5" />
|
||||||
{:else if icon}
|
{:else if icon}
|
||||||
<svelte:component this={icon} class="size-3" />
|
<svelte:component this={icon} class="size-3" />
|
||||||
{:else}
|
{:else}
|
||||||
<CircleCheckIcon class="size-3" />
|
<CircleDotIcon class="size-3" />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<!-- description -->
|
<!-- description -->
|
||||||
<span class="min-w-0 flex-1 leading-snug {entry.status === 'done' ? 'text-muted-foreground' : ''}">
|
<span class="min-w-0 flex-1 leading-snug {entry.status === 'done' ? 'text-muted-foreground' : ''}">
|
||||||
{entry.description}
|
{entry.description}
|
||||||
</span>
|
</span>
|
||||||
{#if hasDetail}
|
<span class="mt-0.5 shrink-0 text-muted-foreground">
|
||||||
<span class="mt-0.5 shrink-0 text-muted-foreground">
|
{#if isOpen}
|
||||||
{#if expanded.has(entry.id)}
|
<ChevronDownIcon class="size-3" />
|
||||||
<ChevronDownIcon class="size-3" />
|
{:else}
|
||||||
{:else}
|
<ChevronRightIcon class="size-3" />
|
||||||
<ChevronRightIcon class="size-3" />
|
{/if}
|
||||||
{/if}
|
</span>
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
</button>
|
||||||
<!-- detail (collapsed) -->
|
<!-- detail -->
|
||||||
{#if hasDetail && expanded.has(entry.id)}
|
{#if isOpen}
|
||||||
<div class="pl-8 pr-3 pb-1">
|
<div class="flex flex-col gap-1.5 pl-8 pr-3 pb-2">
|
||||||
<pre class="whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[10px] text-muted-foreground">{entry.detail}</pre>
|
<div class="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
|
<span class="capitalize">{entry.status}</span>
|
||||||
|
{#if time}<span aria-hidden="true">·</span><span>{time}</span>{/if}
|
||||||
|
{#if entry.toolName}<span aria-hidden="true">·</span><code class="font-mono">{entry.toolName}</code>{/if}
|
||||||
|
</div>
|
||||||
|
{#if entry.args}
|
||||||
|
<div>
|
||||||
|
<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">Called with</p>
|
||||||
|
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.args)}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if entry.detail}
|
||||||
|
<div>
|
||||||
|
{#if entry.args}<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">{entry.status === 'failed' ? 'Error' : 'Result'}</p>{/if}
|
||||||
|
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.detail)}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !entry.args && !entry.detail}
|
||||||
|
<p class="text-[10px] text-muted-foreground/70">No further detail for this step.</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ActivityEntry } from '$lib/stores/activity'
|
import type { ActivityEntry } from '$lib/stores/activity'
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
import Spinner from './Spinner.svelte'
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
{:else if done}
|
{:else if done}
|
||||||
<CheckIcon class="size-3" />
|
<CheckIcon class="size-3" />
|
||||||
{:else}
|
{:else}
|
||||||
<LoaderCircleIcon class="size-3 animate-spin text-primary" />
|
<Spinner class="size-3 text-primary" />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
import Spinner from './Spinner.svelte'
|
||||||
import NetworkIcon from '@lucide/svelte/icons/network'
|
import NetworkIcon from '@lucide/svelte/icons/network'
|
||||||
|
|
||||||
let { approvals }: { approvals: PendingApproval[] } = $props()
|
let { approvals }: { approvals: PendingApproval[] } = $props()
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
{@const secs = elapsedSeconds(e)}
|
{@const secs = elapsedSeconds(e)}
|
||||||
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2 text-xs text-muted-foreground">
|
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2 text-xs text-muted-foreground">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<LoaderCircleIcon class="size-4 shrink-0 animate-spin text-warning" />
|
<Spinner class="size-4 shrink-0 text-warning" />
|
||||||
<span>
|
<span>
|
||||||
{#if p === 'deciding'}
|
{#if p === 'deciding'}
|
||||||
Submitting approval…
|
Submitting approval…
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { planSteps } from '$lib/stores/workspace'
|
import { planSteps } from '$lib/stores/workspace'
|
||||||
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||||
import CircleIcon from '@lucide/svelte/icons/circle'
|
import CircleIcon from '@lucide/svelte/icons/circle'
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
import Spinner from './Spinner.svelte'
|
||||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||||
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
|
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
{:else if step.status === 'failed'}
|
{:else if step.status === 'failed'}
|
||||||
<CircleXIcon class="size-3.5 text-destructive" />
|
<CircleXIcon class="size-3.5 text-destructive" />
|
||||||
{:else if step.status === 'running'}
|
{:else if step.status === 'running'}
|
||||||
<LoaderCircleIcon class="size-3.5 animate-spin text-primary" />
|
<Spinner class="size-3.5 text-primary" />
|
||||||
{:else if step.status === 'skipped' || step.status === 'replaced'}
|
{:else if step.status === 'skipped' || step.status === 'replaced'}
|
||||||
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
|
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
|
||||||
{:else if step.status === 'blocked'}
|
{:else if step.status === 'blocked'}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||||
import CircleIcon from '@lucide/svelte/icons/circle'
|
import CircleIcon from '@lucide/svelte/icons/circle'
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
import Spinner from './Spinner.svelte'
|
||||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||||
|
|
||||||
let digest = $state<SessionDigest | null>(null)
|
let digest = $state<SessionDigest | null>(null)
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
|
|
||||||
{#if $streaming && $currentSession}
|
{#if $streaming && $currentSession}
|
||||||
<div class="flex items-center gap-2 border-b px-3 py-2 text-xs text-muted-foreground">
|
<div class="flex items-center gap-2 border-b px-3 py-2 text-xs text-muted-foreground">
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
|
<Spinner class="size-3 shrink-0 text-primary" />
|
||||||
<span>{toolCount} tool{toolCount === 1 ? '' : 's'} · {runningCount} running</span>
|
<span>{toolCount} tool{toolCount === 1 ? '' : 's'} · {runningCount} running</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
{#if step.status === 'done'}
|
{#if step.status === 'done'}
|
||||||
<CircleCheckIcon class="size-3 text-success" />
|
<CircleCheckIcon class="size-3 text-success" />
|
||||||
{:else if step.status === 'running'}
|
{:else if step.status === 'running'}
|
||||||
<LoaderCircleIcon class="size-3 animate-spin text-primary" />
|
<Spinner class="size-3 text-primary" />
|
||||||
{:else if step.status === 'failed'}
|
{:else if step.status === 'failed'}
|
||||||
<CircleXIcon class="size-3 text-destructive" />
|
<CircleXIcon class="size-3 text-destructive" />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
{:else if t.type === 'tool_result'}
|
{:else if t.type === 'tool_result'}
|
||||||
<CircleCheckIcon class="size-3 text-success" />
|
<CircleCheckIcon class="size-3 text-success" />
|
||||||
{:else}
|
{:else}
|
||||||
<LoaderCircleIcon class="size-3 animate-spin text-primary" />
|
<Spinner class="size-3 text-primary" />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-mono text-[10px] truncate">{toolSummary(t)}</span>
|
<span class="font-mono text-[10px] truncate">{toolSummary(t)}</span>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
|
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
|
||||||
interface Node extends Entity {
|
interface Node extends Entity {
|
||||||
x?: number
|
x?: number
|
||||||
@@ -65,6 +66,14 @@
|
|||||||
let cw = $state(300)
|
let cw = $state(300)
|
||||||
let ch = $state(300)
|
let ch = $state(300)
|
||||||
|
|
||||||
|
// This component sits INSIDE one of TaskContextPanel's own resizable slots
|
||||||
|
// (Scope), so — unlike a top-level section — its total budget can change at
|
||||||
|
// any time from outside (dragging the outer Scope/Plan handle), including
|
||||||
|
// while the detail panel below is open. asideHeight tracks that live budget
|
||||||
|
// so detailHeight can self-clamp to it instead of trusting a one-time seed.
|
||||||
|
let asideEl = $state<HTMLElement | null>(null)
|
||||||
|
let asideHeight = $state(300)
|
||||||
|
|
||||||
function collectSlugs(value: unknown, out: Set<string>) {
|
function collectSlugs(value: unknown, out: Set<string>) {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const m = value.match(SLUG_RE)
|
const m = value.match(SLUG_RE)
|
||||||
@@ -208,6 +217,15 @@
|
|||||||
return () => ro.disconnect()
|
return () => ro.disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!asideEl) return
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
asideHeight = Math.max(entries[0].contentRect.height, 1)
|
||||||
|
})
|
||||||
|
ro.observe(asideEl)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
onDestroy(() => sim?.stop())
|
onDestroy(() => sim?.stop())
|
||||||
|
|
||||||
const healthColor: Record<string, string> = {
|
const healthColor: Record<string, string> = {
|
||||||
@@ -249,6 +267,51 @@
|
|||||||
return typeof end === 'object' ? end.slug : end
|
return typeof end === 'object' ? end.slug : end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── graph / detail resize ───────────────────────────────────────────
|
||||||
|
// Same drag handle, same feel as TaskContextPanel's Scope/Plan/Activity
|
||||||
|
// split — but the graph side stays flex-1 (always auto-fills whatever's
|
||||||
|
// left) rather than tracking its own pixel number. Only detailHeight is
|
||||||
|
// explicit, and it's continuously clamped against asideHeight (this
|
||||||
|
// component's actual live budget) rather than a value seeded once — so
|
||||||
|
// resizing the OUTER Scope section while the detail panel is open can't
|
||||||
|
// push this panel past its container the way a one-time seed could.
|
||||||
|
const MIN_GRAPH = 80
|
||||||
|
const MIN_DETAIL = 80
|
||||||
|
const HANDLE = 6
|
||||||
|
|
||||||
|
let detailHeight = $state(200)
|
||||||
|
let resizing = $state(false)
|
||||||
|
let resizeStartY = $state(0)
|
||||||
|
let resizeStartH = $state(0)
|
||||||
|
|
||||||
|
function maxDetailHeight(): number {
|
||||||
|
return Math.max(MIN_DETAIL, asideHeight - MIN_GRAPH - HANDLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const max = maxDetailHeight()
|
||||||
|
if (detailHeight > max) detailHeight = max
|
||||||
|
})
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
resizing = true
|
||||||
|
resizeStartY = e.clientY
|
||||||
|
resizeStartH = detailHeight
|
||||||
|
window.addEventListener('pointermove', onPointerMove)
|
||||||
|
window.addEventListener('pointerup', onPointerUp)
|
||||||
|
}
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!resizing) return
|
||||||
|
const dy = e.clientY - resizeStartY
|
||||||
|
detailHeight = Math.min(maxDetailHeight(), Math.max(MIN_DETAIL, resizeStartH - dy))
|
||||||
|
}
|
||||||
|
function onPointerUp() {
|
||||||
|
resizing = false
|
||||||
|
window.removeEventListener('pointermove', onPointerMove)
|
||||||
|
window.removeEventListener('pointerup', onPointerUp)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── drag / select ───────────────────────────────────────────────────
|
// ─── drag / select ───────────────────────────────────────────────────
|
||||||
let dragState: { node: Node; moved: boolean } | null = null
|
let dragState: { node: Node; moved: boolean } | null = null
|
||||||
|
|
||||||
@@ -278,7 +341,15 @@
|
|||||||
node.fy = null
|
node.fy = null
|
||||||
sim?.alphaTarget(0)
|
sim?.alphaTarget(0)
|
||||||
dragState = null
|
dragState = null
|
||||||
if (!moved) selected = selected?.slug === node.slug ? null : node
|
if (!moved) {
|
||||||
|
const wasNull = selected === null
|
||||||
|
const next = selected?.slug === node.slug ? null : node
|
||||||
|
// A reasonable starting size on first open — the clamp effect above
|
||||||
|
// keeps it honest against the live container size from here on, so
|
||||||
|
// this doesn't need to be exact.
|
||||||
|
if (next && wasNull) detailHeight = Math.min(maxDetailHeight(), Math.round(ch * 0.45))
|
||||||
|
selected = next
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedRelations = $derived(
|
const selectedRelations = $derived(
|
||||||
@@ -299,7 +370,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="flex h-full min-h-0 flex-col bg-card/40">
|
<aside bind:this={asideEl} class="flex h-full min-h-0 flex-col bg-card/40">
|
||||||
{#if nowTouching}
|
{#if nowTouching}
|
||||||
<div class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary">
|
<div class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary">
|
||||||
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
|
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
|
||||||
@@ -431,11 +502,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if selected}
|
{#if selected}
|
||||||
<div class="max-h-[55%] shrink-0 space-y-3 overflow-y-auto border-t p-3 text-xs">
|
<!-- resize handle -->
|
||||||
|
<div
|
||||||
|
class="h-1.5 shrink-0 cursor-row-resize border-b hover:bg-primary/30 touch-none"
|
||||||
|
onpointerdown={onPointerDown}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
></div>
|
||||||
|
<div class="shrink-0 space-y-3 overflow-y-auto p-3 text-xs" style="height: {detailHeight}px">
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
<span class="font-mono text-sm font-semibold">{selected.slug}</span>
|
<span class="min-w-0 flex-1 truncate font-mono text-sm font-semibold">{selected.slug}</span>
|
||||||
<Badge variant="outline">{selected.type}</Badge>
|
<Badge variant="outline">{selected.type}</Badge>
|
||||||
{#if selected.state}<Badge variant="secondary">{selected.state}</Badge>{/if}
|
{#if selected.state}<Badge variant="secondary">{selected.state}</Badge>{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
onclick={() => (selected = null)}
|
||||||
|
aria-label="Close entity detail"
|
||||||
|
>
|
||||||
|
<XIcon class="size-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if selected.health}
|
{#if selected.health}
|
||||||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import { sessions, currentSession, loadSessions, loadSessionMessages, newChat, deleteSession } from '$lib/stores/chat'
|
|
||||||
import { relativeTime } from '$lib/utils'
|
|
||||||
import { Button } from '$lib/components/ui/button'
|
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
|
||||||
import Trash2Icon from '@lucide/svelte/icons/trash-2'
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
loadSessions()
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void $currentSession
|
|
||||||
loadSessions()
|
|
||||||
})
|
|
||||||
|
|
||||||
let confirmDelete = $state<string | null>(null)
|
|
||||||
|
|
||||||
function handleDelete(e: MouseEvent, id: string) {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (confirmDelete === id) {
|
|
||||||
deleteSession(id)
|
|
||||||
confirmDelete = null
|
|
||||||
} else {
|
|
||||||
confirmDelete = id
|
|
||||||
// Hide confirmation after 3s
|
|
||||||
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClick(sessionId: string) {
|
|
||||||
confirmDelete = null
|
|
||||||
loadSessionMessages(sessionId)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2">
|
|
||||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => newChat()}>
|
|
||||||
<PlusIcon class="size-3.5" />
|
|
||||||
New Task
|
|
||||||
</Button>
|
|
||||||
<ScrollArea class="min-h-0 flex-1">
|
|
||||||
<div class="flex flex-col gap-1 pr-2">
|
|
||||||
{#each $sessions as session (session.id)}
|
|
||||||
<div class="group relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full flex-col items-start gap-0.5 rounded-md border py-1.5 pl-2 pr-7 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
|
|
||||||
onclick={() => handleClick(session.id)}
|
|
||||||
>
|
|
||||||
<span class="min-w-0 max-w-full truncate font-medium">{session.title || 'Untitled'}</span>
|
|
||||||
<span class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
||||||
{relativeTime(session.last_active_at)}
|
|
||||||
{#if session.pending_approvals}
|
|
||||||
<span class="rounded bg-warning/20 px-1 text-[10px] font-medium text-warning">{session.pending_approvals}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="absolute right-1 top-1.5 shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:bg-destructive/20 hover:text-destructive"
|
|
||||||
onclick={(e) => handleDelete(e, session.id)}
|
|
||||||
aria-label={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
|
||||||
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
|
||||||
>
|
|
||||||
{#if confirmDelete === session.id}
|
|
||||||
<span class="text-[10px] font-semibold text-destructive">Sure?</span>
|
|
||||||
{:else}
|
|
||||||
<Trash2Icon class="size-3" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="px-2 py-4 text-center text-xs text-muted-foreground">No sessions yet.</p>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</aside>
|
|
||||||
30
web/src/lib/components/Spinner.svelte
Normal file
30
web/src/lib/components/Spinner.svelte
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// A fading-blade spinner rather than a rotating arc — rotating a single
|
||||||
|
// thin stroke via CSS transform reads as jittery at icon sizes (the arc's
|
||||||
|
// sub-pixel edges shimmer each frame). Cycling opacity across fixed blades
|
||||||
|
// avoids that entirely and is how native OS spinners do it.
|
||||||
|
let { class: className = '' }: { class?: string } = $props()
|
||||||
|
|
||||||
|
const TICKS = 8
|
||||||
|
const DUR = 0.9
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 24 24" class={className} fill="none" aria-hidden="true">
|
||||||
|
{#each Array.from({ length: TICKS }) as _, i (i)}
|
||||||
|
<rect
|
||||||
|
x="11" y="1.5" width="2" height="6" rx="1"
|
||||||
|
fill="currentColor"
|
||||||
|
opacity="0.15"
|
||||||
|
transform="rotate({i * (360 / TICKS)} 12 12)"
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="1;0.15"
|
||||||
|
keyTimes="0;1"
|
||||||
|
dur="{DUR}s"
|
||||||
|
begin="{-(i * (DUR / TICKS)).toFixed(3)}s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</rect>
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
@@ -8,8 +8,13 @@
|
|||||||
import ActivityTimeline from './ActivityTimeline.svelte'
|
import ActivityTimeline from './ActivityTimeline.svelte'
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||||
import TargetIcon from '@lucide/svelte/icons/target'
|
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
import Spinner from './Spinner.svelte'
|
||||||
|
import CircleIcon from '@lucide/svelte/icons/circle'
|
||||||
|
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||||
|
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||||
|
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
|
||||||
|
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
|
||||||
|
|
||||||
onMount(() => startWorkspace())
|
onMount(() => startWorkspace())
|
||||||
|
|
||||||
@@ -55,6 +60,18 @@
|
|||||||
// Plan collapsed status
|
// Plan collapsed status
|
||||||
const planDone = $derived($planSteps.filter((s) => s.status === 'done').length)
|
const planDone = $derived($planSteps.filter((s) => s.status === 'done').length)
|
||||||
const planTotal = $derived($planSteps.length)
|
const planTotal = $derived($planSteps.length)
|
||||||
|
const planPct = $derived(planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0)
|
||||||
|
|
||||||
|
// When there are no plan steps, the empty state depends on WHY: a task that's
|
||||||
|
// actively planning (or streaming its first turn) is genuinely waiting for one,
|
||||||
|
// but a finished task that never planned (a read-only lookup, a direct answer)
|
||||||
|
// will never get one — a perpetual "Awaiting plan…" there is misleading.
|
||||||
|
const planPhase = $derived.by<'drafting' | 'none' | 'idle'>(() => {
|
||||||
|
const st = $currentTask?.status
|
||||||
|
if (st === 'done' || st === 'failed' || st === 'abandoned') return 'none'
|
||||||
|
if (st === 'planning' || $streaming) return 'drafting'
|
||||||
|
return 'idle'
|
||||||
|
})
|
||||||
|
|
||||||
// Activity collapsed status
|
// Activity collapsed status
|
||||||
const activityRunning = $derived($activityLog.filter((e) => e.status === 'running').length)
|
const activityRunning = $derived($activityLog.filter((e) => e.status === 'running').length)
|
||||||
@@ -113,44 +130,95 @@
|
|||||||
{#if planOpen}
|
{#if planOpen}
|
||||||
<div style="height: {heights[1]}px" class="flex flex-col overflow-y-auto">
|
<div style="height: {heights[1]}px" class="flex flex-col overflow-y-auto">
|
||||||
{#if $currentTask?.goal}
|
{#if $currentTask?.goal}
|
||||||
<div class="flex items-center gap-1.5 px-3 py-1.5 text-xs">
|
<div class="flex items-start gap-2 px-3 py-2">
|
||||||
<TargetIcon class="size-3 shrink-0 text-muted-foreground" />
|
<MilestoneIcon class="mt-0.5 size-3 shrink-0 text-primary" />
|
||||||
<span class="truncate text-muted-foreground">{$currentTask.goal}</span>
|
<span class="text-xs leading-snug text-foreground/90">{$currentTask.goal}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if planTotal > 0}
|
{#if planTotal > 0}
|
||||||
<div class="flex items-center justify-between px-3 pb-1 text-[11px] text-muted-foreground">
|
<div class="px-3 pb-2.5">
|
||||||
<span>{planDone}/{planTotal} done</span>
|
<div class="mb-1.5 flex items-baseline justify-between text-[11px]">
|
||||||
|
<span class="font-medium text-foreground">{planDone} of {planTotal} done</span>
|
||||||
|
<span class="tabular-nums text-muted-foreground">{planPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div class="h-full rounded-full bg-primary transition-all duration-500 ease-out" style="width: {planPct}%"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mx-3 h-1 rounded-full bg-muted">
|
<ol class="flex flex-col overflow-y-auto px-2 pb-2 text-[11px]">
|
||||||
<div class="h-full rounded-full bg-primary transition-all duration-500" style="width: {planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0}%"></div>
|
{#each $planSteps as step, i (step.id)}
|
||||||
</div>
|
{@const isDone = step.status === 'done'}
|
||||||
<ol class="flex flex-col gap-0.5 overflow-y-auto px-3 py-1 text-[11px]">
|
{@const isRunning = step.status === 'running'}
|
||||||
{#each $planSteps as step (step.id)}
|
<li class="relative flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors {isRunning ? 'bg-primary/5' : ''}">
|
||||||
<li class="flex items-start gap-1.5 {step.status === 'done' ? 'text-muted-foreground line-through decoration-muted-foreground/40' : ''}">
|
{#if i < $planSteps.length - 1}
|
||||||
<span class="mt-0.5 shrink-0">
|
<span class="pointer-events-none absolute bottom-[-2px] left-[13.5px] top-[22px] w-px bg-border" aria-hidden="true"></span>
|
||||||
{#if step.status === 'done'}
|
{/if}
|
||||||
<div class="size-2 rounded-full bg-success" />
|
<span class="relative z-10 mt-px flex size-3.5 shrink-0 items-center justify-center rounded-full bg-background">
|
||||||
{:else if step.status === 'running'}
|
{#if isDone}
|
||||||
<LoaderCircleIcon class="size-2.5 animate-spin text-primary" />
|
<CircleCheckIcon class="size-3.5 text-primary" />
|
||||||
|
{:else if isRunning}
|
||||||
|
<Spinner class="size-3.5 text-primary" />
|
||||||
{:else if step.status === 'failed'}
|
{:else if step.status === 'failed'}
|
||||||
<div class="size-2 rounded-full bg-destructive" />
|
<CircleXIcon class="size-3.5 text-destructive" />
|
||||||
|
{:else if step.status === 'blocked'}
|
||||||
|
<CirclePauseIcon class="size-3.5 text-warning" />
|
||||||
|
{:else if step.status === 'skipped' || step.status === 'replaced'}
|
||||||
|
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="size-2 rounded-full bg-muted-foreground/30" />
|
<CircleIcon class="size-3.5 text-muted-foreground/40" />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="leading-tight">{step.title}</span>
|
<span
|
||||||
|
class="min-w-0 flex-1 leading-snug {isDone
|
||||||
|
? 'text-muted-foreground line-through decoration-muted-foreground/40'
|
||||||
|
: isRunning
|
||||||
|
? 'font-medium text-foreground'
|
||||||
|
: 'text-muted-foreground'}"
|
||||||
|
>{step.title}</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
|
{:else if planPhase === 'drafting'}
|
||||||
|
<div class="flex flex-col items-center gap-3 px-3 py-6 text-center">
|
||||||
|
<svg viewBox="0 0 140 88" class="h-16 w-auto text-primary" fill="none">
|
||||||
|
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||||
|
<circle cx="16" cy="20" r="4.5" fill="currentColor">
|
||||||
|
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<line x1="30" y1="20" x2="124" y2="20" opacity="0.4">
|
||||||
|
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" repeatCount="indefinite" />
|
||||||
|
</line>
|
||||||
|
<circle cx="16" cy="44" r="4.5" fill="currentColor">
|
||||||
|
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" begin="0.25s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<line x1="30" y1="44" x2="102" y2="44" opacity="0.4">
|
||||||
|
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" begin="0.25s" repeatCount="indefinite" />
|
||||||
|
</line>
|
||||||
|
<circle cx="16" cy="68" r="4.5" fill="currentColor">
|
||||||
|
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" begin="0.5s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<line x1="30" y1="68" x2="80" y2="68" opacity="0.4">
|
||||||
|
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" begin="0.5s" repeatCount="indefinite" />
|
||||||
|
</line>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<p class="text-xs text-muted-foreground">Drafting a plan…</p>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
|
<div class="flex flex-col items-center gap-3 px-3 py-6 text-center">
|
||||||
<div class="flex gap-1">
|
<svg viewBox="0 0 140 88" class="h-16 w-auto text-muted-foreground/40" fill="none">
|
||||||
<div class="size-1.5 rounded-full bg-muted-foreground/20" />
|
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||||
<div class="size-1.5 rounded-full bg-muted-foreground/30" />
|
<circle cx="16" cy="20" r="4.5" fill="currentColor" opacity="0.7" />
|
||||||
<div class="size-1.5 rounded-full bg-muted-foreground/20" />
|
<line x1="30" y1="20" x2="124" y2="20" opacity="0.35" />
|
||||||
</div>
|
<circle cx="16" cy="44" r="4.5" fill="currentColor" opacity="0.45" />
|
||||||
<p class="text-xs text-muted-foreground">Awaiting plan…</p>
|
<line x1="30" y1="44" x2="102" y2="44" opacity="0.25" />
|
||||||
|
<circle cx="16" cy="68" r="4.5" fill="none" opacity="0.3" />
|
||||||
|
<line x1="30" y1="68" x2="80" y2="68" opacity="0.15" stroke-dasharray="2.5 3.5" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<p class="max-w-[14rem] text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{planPhase === 'none' ? 'Handled directly — no plan needed' : 'No plan for this task yet'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -172,10 +240,10 @@
|
|||||||
onclick={() => (activityOpen = !activityOpen)}
|
onclick={() => (activityOpen = !activityOpen)}
|
||||||
>
|
>
|
||||||
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||||
<span>Activity</span>
|
<span>Event log</span>
|
||||||
{#if !activityOpen}
|
{#if !activityOpen}
|
||||||
{#if $streaming && activityRunning > 0}
|
{#if $streaming && activityRunning > 0}
|
||||||
<LoaderCircleIcon class="size-3 animate-spin text-primary" />
|
<Spinner class="size-3 text-primary" />
|
||||||
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
|
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span>
|
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
import Spinner from './Spinner.svelte'
|
||||||
|
|
||||||
let { tools, unmatched, active = false }: { tools: ToolCallResult[]; unmatched?: ToolCallResult[]; active?: boolean } = $props()
|
let { tools, unmatched, active = false }: { tools: ToolCallResult[]; unmatched?: ToolCallResult[]; active?: boolean } = $props()
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-1.5 px-2 py-1 hover:bg-muted/30">
|
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-1.5 px-2 py-1 hover:bg-muted/30">
|
||||||
<span class="shrink-0">
|
<span class="shrink-0">
|
||||||
{#if active && doneCount < total}
|
{#if active && doneCount < total}
|
||||||
<LoaderCircleIcon class="size-3 animate-spin text-primary" aria-hidden="true" />
|
<Spinner class="size-3 text-primary" />
|
||||||
{:else if hasError}
|
{:else if hasError}
|
||||||
<XIcon class="size-3 text-destructive" aria-hidden="true" />
|
<XIcon class="size-3 text-destructive" aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
{:else if tool.type === 'tool_result'}
|
{:else if tool.type === 'tool_result'}
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||||
{:else}
|
{:else}
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
|
<Spinner class="size-3 shrink-0 text-primary" />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="font-mono text-[11px] truncate">{toolLabel(tool)}</span>
|
<span class="font-mono text-[11px] truncate">{toolLabel(tool)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface ActivityEntry {
|
|||||||
'approval'
|
'approval'
|
||||||
description: string
|
description: string
|
||||||
detail?: string
|
detail?: string
|
||||||
|
args?: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
toolName?: string
|
toolName?: string
|
||||||
stepSeq?: number
|
stepSeq?: number
|
||||||
@@ -19,6 +20,26 @@ export interface ActivityEntry {
|
|||||||
status: 'running' | 'done' | 'failed'
|
status: 'running' | 'done' | 'failed'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detail text is kept full-length (not hard-truncated to a preview snippet)
|
||||||
|
// so the expanded view has something worth pretty-printing — capped only as
|
||||||
|
// a safety net against pathological payloads (a full fleet dump, etc).
|
||||||
|
const DETAIL_MAX = 8000
|
||||||
|
|
||||||
|
function summarizeArgs(args: unknown): string | undefined {
|
||||||
|
if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined
|
||||||
|
if (Object.keys(args).length === 0) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.stringify(args)
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyResult(result: unknown): string {
|
||||||
|
const s = typeof result === 'string' ? result : JSON.stringify(result ?? '')
|
||||||
|
return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s
|
||||||
|
}
|
||||||
|
|
||||||
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) => {
|
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) => {
|
||||||
const entries: ActivityEntry[] = []
|
const entries: ActivityEntry[] = []
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -64,6 +85,7 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs,
|
|||||||
id: t.id ?? `tool_${mi}_${entryIdx++}`,
|
id: t.id ?? `tool_${mi}_${entryIdx++}`,
|
||||||
type: 'tool_running',
|
type: 'tool_running',
|
||||||
description: label,
|
description: label,
|
||||||
|
args: summarizeArgs(t.args),
|
||||||
timestamp: now - ($msgs.length - mi) * 1000,
|
timestamp: now - ($msgs.length - mi) * 1000,
|
||||||
toolName: t.name,
|
toolName: t.name,
|
||||||
stepSeq: stepTag,
|
stepSeq: stepTag,
|
||||||
@@ -77,19 +99,25 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs,
|
|||||||
if (running && t.error) {
|
if (running && t.error) {
|
||||||
running.type = 'tool_error'
|
running.type = 'tool_error'
|
||||||
running.status = 'failed'
|
running.status = 'failed'
|
||||||
running.description = `${t.name}: ${t.error.slice(0, 80)}`
|
running.description = `${label}: ${t.error.slice(0, 80)}`
|
||||||
|
running.detail = t.error
|
||||||
} else if (running) {
|
} else if (running) {
|
||||||
running.type = 'tool_done'
|
running.type = 'tool_done'
|
||||||
running.status = 'done'
|
running.status = 'done'
|
||||||
running.detail = typeof t.result === 'string'
|
running.detail = stringifyResult(t.result)
|
||||||
? t.result.slice(0, 200)
|
|
||||||
: JSON.stringify(t.result ?? '').slice(0, 200)
|
|
||||||
} else {
|
} else {
|
||||||
|
// Historical/persisted tool calls arrive as one merged record (args
|
||||||
|
// + result on the same object, see mergeToolCalls in chat.ts) rather
|
||||||
|
// than a separate tool_use/tool_result pair — there's never a
|
||||||
|
// "running" entry to attach to, so this branch has to build the
|
||||||
|
// full entry itself. It used to fall back to the raw tool name
|
||||||
|
// (e.g. "get_entity") instead of the humanized label here.
|
||||||
entries.push({
|
entries.push({
|
||||||
id: t.id ?? `tool_${mi}_${entryIdx++}`,
|
id: t.id ?? `tool_${mi}_${entryIdx++}`,
|
||||||
type: t.error ? 'tool_error' : 'tool_done',
|
type: t.error ? 'tool_error' : 'tool_done',
|
||||||
description: t.error ? `${t.name}: ${t.error.slice(0, 80)}` : t.name,
|
description: t.error ? `${label}: ${t.error.slice(0, 80)}` : label,
|
||||||
detail: !t.error ? (typeof t.result === 'string' ? t.result.slice(0, 200) : '') : undefined,
|
detail: t.error ? t.error : stringifyResult(t.result),
|
||||||
|
args: summarizeArgs(t.args),
|
||||||
timestamp: now - ($msgs.length - mi) * 1000,
|
timestamp: now - ($msgs.length - mi) * 1000,
|
||||||
toolName: t.name,
|
toolName: t.name,
|
||||||
stepSeq: stepTag,
|
stepSeq: stepTag,
|
||||||
@@ -181,6 +209,8 @@ function toolActivityLabel(t: ToolCallResult): string {
|
|||||||
case 'complete_task': return 'Complete task'
|
case 'complete_task': return 'Complete task'
|
||||||
case 'ping_service': return 'Check service'
|
case 'ping_service': return 'Check service'
|
||||||
case 'ask_operator': return 'Ask operator'
|
case 'ask_operator': return 'Ask operator'
|
||||||
default: return t.name
|
// Unmapped tool (new/uncommon) — humanize the raw name rather than
|
||||||
|
// showing it verbatim, e.g. "revoke_execution" -> "Revoke execution".
|
||||||
|
default: return t.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } from '$lib/stores/chat'
|
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } from '$lib/stores/chat'
|
||||||
import { activityLog } from '$lib/stores/activity'
|
import { activityLog } from '$lib/stores/activity'
|
||||||
import SessionRail from '$lib/components/SessionRail.svelte'
|
|
||||||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||||
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
||||||
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
|
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
|
||||||
@@ -105,11 +104,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full min-h-0">
|
<div class="flex h-full min-h-0">
|
||||||
{#if showRail}
|
|
||||||
<div class="hidden md:block">
|
|
||||||
<SessionRail />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 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 max-w-3xl flex-col gap-5 p-4">
|
||||||
@@ -247,18 +241,14 @@
|
|||||||
<style>
|
<style>
|
||||||
/* ── Art Nouveau chat styling ── */
|
/* ── Art Nouveau chat styling ── */
|
||||||
|
|
||||||
/* Assistant message wrapper — ornamental frame */
|
/* Assistant message wrapper */
|
||||||
.assistant-msg {
|
.assistant-msg {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-left: 2px solid var(--primary);
|
|
||||||
border-image: linear-gradient(to bottom, var(--primary), transparent 80%) 1;
|
|
||||||
padding-left: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User message — softened bubble with inner glow */
|
/* User message — soft terracotta bubble, gentle lift */
|
||||||
.user-msg {
|
.user-msg {
|
||||||
box-shadow: 0 0 12px -4px var(--primary);
|
box-shadow: 0 1px 8px -4px var(--primary);
|
||||||
border: 1px solid var(--primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prose overrides */
|
/* Prose overrides */
|
||||||
@@ -325,24 +315,32 @@
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
||||||
|
margin separates sections; the first heading in a message doesn't. */
|
||||||
.prose-chat :global(h1),
|
.prose-chat :global(h1),
|
||||||
.prose-chat :global(h2),
|
.prose-chat :global(h2),
|
||||||
.prose-chat :global(h3) {
|
.prose-chat :global(h3) {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0.75rem 0 0.375rem;
|
margin: 1.15rem 0 0.4rem;
|
||||||
font-size: 1em;
|
font-size: 1.03em;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
.prose-chat :global(:first-child):is(h1, h2, h3) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
.prose-chat :global(h1)::after,
|
.prose-chat :global(h1)::after,
|
||||||
.prose-chat :global(h2)::after,
|
.prose-chat :global(h2)::after,
|
||||||
.prose-chat :global(h3)::after {
|
.prose-chat :global(h3)::after {
|
||||||
content: '';
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
height: 1px;
|
width: 2.5rem;
|
||||||
margin-top: 2px;
|
height: 2px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-radius: 1px;
|
||||||
background: linear-gradient(to right, var(--primary), transparent);
|
background: linear-gradient(to right, var(--primary), transparent);
|
||||||
opacity: 0.5;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-chat :global(table) {
|
.prose-chat :global(table) {
|
||||||
@@ -388,8 +386,11 @@
|
|||||||
background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
|
background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
|
||||||
|
terracotta accent stay meaningful (code, headings, links). */
|
||||||
.prose-chat :global(strong) {
|
.prose-chat :global(strong) {
|
||||||
color: var(--primary);
|
color: var(--foreground);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-chat :global(a) {
|
.prose-chat :global(a) {
|
||||||
|
|||||||
Reference in New Issue
Block a user