diff --git a/desktop-new/AGENTS.md b/desktop-new/AGENTS.md index 1a8a8cadf7..e041514d20 100644 --- a/desktop-new/AGENTS.md +++ b/desktop-new/AGENTS.md @@ -113,7 +113,7 @@ is not glass, so it is reachable only through the `glass-primary` / | Choice | Reason | |---|---| -| **Base UI** | Behaviour, accessibility, keyboard, and positioning with zero appearance. The visual language is authored here, not inherited then overridden. | +| **Base UI** | Behaviour, accessibility, keyboard, and positioning with zero appearance. The visual language is authored here, not inherited then overridden. **Before building any interactive shared component, check Base UI first; when it has the behavior, wrap and compose that primitive rather than recreating its events, focus, positioning, portal, or dismissal logic.** A native element is appropriate only when Base UI has no corresponding primitive or the component is semantically static. | | **Tailwind v4** | Tokens are defined in CSS via `@theme`; the CSS *is* the config. No JS config file. | | **Own colour tokens** | Not shadcn. Its vocabulary — `muted-foreground`, `secondary-foreground` — is what made colour illegible in the existing client. | | **TanStack Router** | File-based routes, same as the existing client. | diff --git a/desktop-new/DESIGN.md b/desktop-new/DESIGN.md index 9d1aacfade..84c5bd12a8 100644 --- a/desktop-new/DESIGN.md +++ b/desktop-new/DESIGN.md @@ -386,6 +386,8 @@ Every addition lands in `src/shared/tokens/registry.ts` in the same change that ## Components - **Compose existing components freely. Never reimplement one.** +- **Focus is keyboard-only visual navigation.** Pointer focus stays quiet; keyboard navigation gives the focused control—not its container—a visible focus ring. Browsers treat clicked text fields as `:focus-visible`, so the app-root keyboard-navigation fact gates every focus treatment. Never add a `:focus-within` focus ring to a container: it duplicates the child control's signal and makes pointer focus noisy. +- **Base UI is the behavior layer.** Before writing an interactive shared component, inspect Base UI for the matching primitive. When one exists, wrap and compose it; Base UI owns focus, keyboard behavior, positioning, portals, and dismissal, while Buzz owns the visual language and product semantics. Reach for native elements only when Base UI has no matching primitive or the component is semantically static. - **Need a variant that doesn't exist? Add it, mark it proposed.** If a variant almost fits but you would cancel several of its states, the base is wrong for the job and the system is missing a variant. - **Never add a boolean prop for a visual difference.** Variants are enumerable, so an agent can read the list and pick; booleans multiply, and nobody designed most of the combinations. New props are for data and behaviour, not appearance. - **Used by one feature? It lives in that feature's folder.** Used by two? Propose it as shared. The folder is the namespace. diff --git a/desktop-new/scripts/check-contrast.mjs b/desktop-new/scripts/check-contrast.mjs index d4fdb9306c..bf3f4ae8fe 100644 --- a/desktop-new/scripts/check-contrast.mjs +++ b/desktop-new/scripts/check-contrast.mjs @@ -57,7 +57,7 @@ const META_ROLES = new Set(["--text-tertiary"]); * its step here. It still belongs in this list: a row under the cursor is a * surface text sits on, whatever it is called. */ -const SURFACES = ["--bg-panel", "--bg-float", "--bg-inset", "--neutral-4"]; +const SURFACES = ["--bg-panel", "--bg-float", "--neutral-2", "--neutral-4"]; /** * Text that must be readable on every neutral surface. diff --git a/desktop-new/src/app/routes/design/composer.tsx b/desktop-new/src/app/routes/design/composer.tsx new file mode 100644 index 0000000000..8200f42dfa --- /dev/null +++ b/desktop-new/src/app/routes/design/composer.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ComposerPage } from "@/features/design-system/ui/ComposerPage"; + +export const Route = createFileRoute("/design/composer")({ + component: ComposerPage, +}); diff --git a/desktop-new/src/app/useKeyboardFocusVisibility.ts b/desktop-new/src/app/useKeyboardFocusVisibility.ts new file mode 100644 index 0000000000..dff36d37bd --- /dev/null +++ b/desktop-new/src/app/useKeyboardFocusVisibility.ts @@ -0,0 +1,48 @@ +import { useEffect } from "react"; + +const KEYBOARD_NAVIGATION_KEYS = new Set([ + "Tab", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "End", + "Home", + "PageDown", + "PageUp", +]); + +/** + * Makes keyboard focus visible without treating a pointer click in a text field + * as keyboard navigation. Browsers intentionally match :focus-visible for text + * inputs even after a click, so this app-level modality fact gates the visual + * treatment consistently for every control. + */ +export function useKeyboardFocusVisibility() { + useEffect(() => { + const root = document.documentElement; + const useKeyboardNavigation = (event: KeyboardEvent) => { + if ( + KEYBOARD_NAVIGATION_KEYS.has(event.key) && + !event.metaKey && + !event.ctrlKey && + !event.altKey + ) { + root.dataset.keyboardNavigation = ""; + } + }; + const usePointerNavigation = () => { + delete root.dataset.keyboardNavigation; + }; + + window.addEventListener("keydown", useKeyboardNavigation, true); + window.addEventListener("pointerdown", usePointerNavigation, true); + window.addEventListener("touchstart", usePointerNavigation, true); + return () => { + window.removeEventListener("keydown", useKeyboardNavigation, true); + window.removeEventListener("pointerdown", usePointerNavigation, true); + window.removeEventListener("touchstart", usePointerNavigation, true); + delete root.dataset.keyboardNavigation; + }; + }, []); +} diff --git a/desktop-new/src/features/agent-activity/activityProjection.test.ts b/desktop-new/src/features/agent-activity/activityProjection.test.ts new file mode 100644 index 0000000000..bbf36e028a --- /dev/null +++ b/desktop-new/src/features/agent-activity/activityProjection.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { reduceActivity } from "./activityProjection"; + +const event = (kind: string, status: string) => ({ + seq: 1, + timestamp: "2026-09-08T03:00:00.000Z", + kind, + channelId: "design", + sessionId: "session", + turnId: "turn", + agentPubkey: "agent", + payload: { agentName: "Vogue", title: kind, status }, +}); + +describe("reduceActivity", () => { + it("keeps a turn working after an individual tool completes", () => { + const started = reduceActivity(new Map(), event("turn_started", "running")); + const updated = reduceActivity( + started, + event("tool_call_completed", "completed"), + ); + + expect([...updated.values()][0]?.status).toBe("running"); + }); + + it("settles only when the turn itself reaches a terminal event", () => { + const started = reduceActivity(new Map(), event("turn_started", "running")); + const completed = reduceActivity( + started, + event("turn_completed", "completed"), + ); + + expect([...completed.values()][0]?.status).toBe("completed"); + }); +}); diff --git a/desktop-new/src/features/sessions/activityProjection.ts b/desktop-new/src/features/agent-activity/activityProjection.ts similarity index 69% rename from desktop-new/src/features/sessions/activityProjection.ts rename to desktop-new/src/features/agent-activity/activityProjection.ts index 2949dc76da..ebe510b7f5 100644 --- a/desktop-new/src/features/sessions/activityProjection.ts +++ b/desktop-new/src/features/agent-activity/activityProjection.ts @@ -1,9 +1,5 @@ -import type { - ActivityItem, - ActivityStatus, - AgentTurn, - ObserverEvent, -} from "./types"; +import type { ObserverEvent } from "@/shared/runtime/observerEvent"; +import type { ActivityItem, ActivityStatus, AgentTurn } from "./types"; const TERMINAL_KINDS: Record = { turn_completed: "completed", @@ -38,13 +34,13 @@ function statusFor(event: ObserverEvent): ActivityStatus { } function itemLabel(event: ObserverEvent): string { - const title = + return ( text(event.payload.title) ?? text(event.payload.toolCallTitle) ?? text(event.payload.toolName) ?? - text(event.payload.name); - if (title) return title; - return event.kind.replaceAll("_", " "); + text(event.payload.name) ?? + event.kind.replaceAll("_", " ") + ); } function itemId(event: ObserverEvent): string { @@ -68,6 +64,10 @@ function eventToItem(event: ObserverEvent): ActivityItem { }; } +/** + * Projects one owner-private observer event into a stable agent turn. The + * capability owns this projection once; conversations only read its result. + */ export function reduceActivity( current: Map, event: ObserverEvent, @@ -89,18 +89,34 @@ export function reduceActivity( if (existingIndex >= 0) items[existingIndex] = incoming; else items.push(incoming); items.sort((left, right) => left.timestamp.localeCompare(right.timestamp)); + // A completed tool call is evidence within an active turn, not completion + // of the turn itself. Only a terminal turn event may replace its aggregate + // state; otherwise the dock would disappear midway through an agent's work. + const isTerminalTurnEvent = event.kind in TERMINAL_KINDS; + const turnStatus = isTerminalTurnEvent + ? statusFor(event) + : previous + ? previous.status + : statusFor(event); + next.set(key, { key, agentPubkey: event.agentPubkey, agentName: text(event.payload.agentName) ?? fallbackAgentName, sessionId, turnId: event.turnId, - status: statusFor(event), + channelId: event.channelId, + status: turnStatus, items, }); return next; } +/** The most recent activity item is the compact dock's truthful summary. */ +export function latestActivityItem(turn: AgentTurn): ActivityItem | undefined { + return turn.items.at(-1); +} + export function partitionActivity(turn: AgentTurn): { visibleItems: ActivityItem[]; hiddenItems: ActivityItem[]; @@ -115,12 +131,12 @@ export function partitionActivity(turn: AgentTurn): { ) .map((item) => item.id), ); - const visibleItems = turn.items.filter( - (item) => recentIds.has(item.id) || urgentIds.has(item.id), - ); - const visibleIds = new Set(visibleItems.map((item) => item.id)); return { - visibleItems, - hiddenItems: turn.items.filter((item) => !visibleIds.has(item.id)), + visibleItems: turn.items.filter( + (item) => recentIds.has(item.id) || urgentIds.has(item.id), + ), + hiddenItems: turn.items.filter( + (item) => !recentIds.has(item.id) && !urgentIds.has(item.id), + ), }; } diff --git a/desktop-new/src/features/agent-activity/types.ts b/desktop-new/src/features/agent-activity/types.ts new file mode 100644 index 0000000000..ec4ca9449d --- /dev/null +++ b/desktop-new/src/features/agent-activity/types.ts @@ -0,0 +1,28 @@ +export type ActivityStatus = + | "pending" + | "running" + | "completed" + | "failed" + | "cancelled" + | "needs_you" + | "unavailable"; + +export type ActivityItem = { + id: string; + label: string; + detail?: string; + status: ActivityStatus; + timestamp: string; +}; + +/** Owner-private activity from an agent turn. Not a shared conversation event. */ +export type AgentTurn = { + key: string; + agentPubkey: string; + agentName: string; + sessionId: string; + turnId: string; + channelId: string; + status: ActivityStatus; + items: ActivityItem[]; +}; diff --git a/desktop-new/src/features/agent-activity/ui/AgentActivityRail.tsx b/desktop-new/src/features/agent-activity/ui/AgentActivityRail.tsx new file mode 100644 index 0000000000..cc53963661 --- /dev/null +++ b/desktop-new/src/features/agent-activity/ui/AgentActivityRail.tsx @@ -0,0 +1,32 @@ +import { latestActivityItem } from "../activityProjection"; +import type { AgentTurn } from "../types"; + +/** + * Compact, non-blocking status beneath a Conversation Composer. + * + * It renders only active turns. ConversationDock owns its space; this component + * owns the activity's readable, motion-safe working treatment. + */ +export function AgentActivityRail({ turns }: { turns: AgentTurn[] }) { + if (turns.length === 0) return null; + + return ( +
+ {turns.map((turn) => { + const current = latestActivityItem(turn); + const label = `${turn.agentName} is working${ + current ? ` · ${current.label}` : "" + }`; + return ( + + {label} + + ); + })} +
+ ); +} diff --git a/desktop-new/src/features/agent-activity/useAgentActivity.ts b/desktop-new/src/features/agent-activity/useAgentActivity.ts new file mode 100644 index 0000000000..ba16727b7b --- /dev/null +++ b/desktop-new/src/features/agent-activity/useAgentActivity.ts @@ -0,0 +1,47 @@ +import { useEffect, useMemo, useState } from "react"; +import { runtime } from "@/shared/runtime/client"; +import type { ObserverEvent } from "@/shared/runtime/observerEvent"; +import { reduceActivity } from "./activityProjection"; +import type { AgentTurn } from "./types"; + +/** + * The one app-scoped ingestion point for owner-private observer telemetry. + * + * Mount this once above conversation placements. Consumers receive a read model + * filtered to their current channel rather than starting their own observer + * subscription. + */ +export function useAgentActivity() { + const [turnsByKey, setTurnsByKey] = useState>( + () => new Map(), + ); + + useEffect(() => { + let disposed = false; + let stop: (() => void) | undefined; + + void runtime + .observe((event: ObserverEvent) => { + if (!disposed) + setTurnsByKey((current) => reduceActivity(current, event)); + }) + .then((cleanup) => { + if (disposed) cleanup(); + else stop = cleanup; + }); + + return () => { + disposed = true; + stop?.(); + }; + }, []); + + const turns = useMemo(() => [...turnsByKey.values()], [turnsByKey]); + + return { + /** Reads activity for a conversation; it never owns the observer stream. */ + forChannel(channelId: string) { + return turns.filter((turn) => turn.channelId === channelId); + }, + }; +} diff --git a/desktop-new/src/features/agents/ui/AgentsWorkspace.tsx b/desktop-new/src/features/agents/ui/AgentsWorkspace.tsx index c7bca253f4..8b40d76abf 100644 --- a/desktop-new/src/features/agents/ui/AgentsWorkspace.tsx +++ b/desktop-new/src/features/agents/ui/AgentsWorkspace.tsx @@ -5,10 +5,24 @@ import { IconRobot, } from "@tabler/icons-react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { runtime } from "@/shared/runtime/client"; +import { readRelayUrl } from "@/shared/community/useCommunity"; +import { readIdentity } from "@/shared/identity/useIdentity"; import { agentRuntime } from "../runtime"; import type { AgentRuntime, ManagedAgent } from "../types"; +/** + * The community and signer a managed agent is launched against. Read from the + * shared facts rather than the backend adapter, so this feature cannot disagree + * with the rest of the app about who is running it or where. + */ +async function readAgentScope() { + const [relayUrl, identity] = await Promise.all([ + readRelayUrl(), + readIdentity(), + ]); + return { relayUrl, signerPubkey: identity.pubkey }; +} + function runtimeReady(runtime: AgentRuntime) { return ( runtime.availability === "available" && @@ -34,7 +48,7 @@ export function AgentsNavigator({ Agents @@ -123,7 +123,7 @@ export function ComposerSpikePage() { data-testid="insert-alex" onMouseDown={keepFocus} onClick={() => insertChip(ALEX)} - className="rounded-md bg-inset px-3 py-1 text-body text-primary" + className="rounded-md bg-neutral-2 px-3 py-1 text-body text-primary" > Insert Alex @@ -132,7 +132,7 @@ export function ComposerSpikePage() { data-testid="clear" onMouseDown={keepFocus} onClick={() => editor.chain().focus().clearContent(true).run()} - className="rounded-md bg-inset px-3 py-1 text-body text-primary" + className="rounded-md bg-neutral-2 px-3 py-1 text-body text-primary" > Clear diff --git a/desktop-new/src/features/composer/ui/EmojiPicker.tsx b/desktop-new/src/features/composer/ui/EmojiPicker.tsx new file mode 100644 index 0000000000..71bf573fd2 --- /dev/null +++ b/desktop-new/src/features/composer/ui/EmojiPicker.tsx @@ -0,0 +1,91 @@ +import { Popover as BasePopover } from "@base-ui/react/popover"; +import { IconMoodSmile } from "@tabler/icons-react"; +import { useState, type RefObject } from "react"; + +import { Button } from "@/shared/ui/Button"; +import { IconButton } from "@/shared/ui/IconButton"; + +const EMOJI = [ + "😀", + "😂", + "😊", + "😍", + "🤔", + "🎉", + "👍", + "👀", + "🙏", + "💜", + "🔥", + "✅", + "🚀", + "💡", + "🫡", + "👋", + "🙌", + "💯", + "🤝", + "✨", +] as const; + +/** + * The first Composer expression control: a small, curated emoji picker. + * + * Base UI owns the anchored, portal-rendered popup and its dismissal. The + * composer owns selection restoration: picker interactions must not turn a + * chosen emoji into an editor-focus detour. + */ +export function EmojiPicker({ + disabled, + textarea, + onInsert, +}: { + disabled?: boolean; + textarea: RefObject; + onInsert: (emoji: string) => void; +}) { + const [open, setOpen] = useState(false); + + return ( + + + ); +} diff --git a/desktop-new/src/features/composer/ui/MessageComposer.tsx b/desktop-new/src/features/composer/ui/MessageComposer.tsx new file mode 100644 index 0000000000..cdffca27fc --- /dev/null +++ b/desktop-new/src/features/composer/ui/MessageComposer.tsx @@ -0,0 +1,181 @@ +import { + IconArrowUp, + IconPaperclip, + IconTypography, + IconMicrophone, +} from "@tabler/icons-react"; +import { type FormEvent, useEffect, useRef, useState } from "react"; + +import { IconButton } from "@/shared/ui/IconButton"; + +import { EmojiPicker } from "./EmojiPicker"; + +/** + * The first production Composer composition. + * + * It owns visual authoring and local submission feedback only. Draft scope and + * the send transaction belong to its caller. Attachment, emoji, formatting, + * and voice controls are intentionally visual seams in this pass: their real + * behavior arrives one capability at a time, not as decorative placeholders. + */ +export function MessageComposer({ + disabled, + draft, + onDraftChange, + onSend, + placeholder = "Message the session", +}: { + disabled?: boolean; + draft: string; + onDraftChange: (value: string) => void; + onSend: (content: string) => Promise; + placeholder?: string; +}) { + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const textarea = useRef(null); + const selection = useRef({ start: 0, end: 0 }); + + useEffect(() => { + textarea.current?.focus(); + }, []); + + async function submit(event: FormEvent) { + event.preventDefault(); + const content = draft.trim(); + if (!content || sending || disabled) return; + + setSending(true); + setError(null); + try { + await onSend(content); + onDraftChange(""); + } catch (caught) { + setError( + caught instanceof Error ? caught.message : "Your message was not sent.", + ); + } finally { + setSending(false); + textarea.current?.focus(); + } + } + + function rememberSelection() { + const element = textarea.current; + if (!element) return; + selection.current = { + start: element.selectionStart, + end: element.selectionEnd, + }; + } + + function insertEmoji(emoji: string) { + const { start, end } = selection.current; + const next = `${draft.slice(0, start)}${emoji}${draft.slice(end)}`; + onDraftChange(next); + requestAnimationFrame(() => { + const element = textarea.current; + if (!element) return; + const position = start + emoji.length; + element.focus(); + element.setSelectionRange(position, position); + selection.current = { start: position, end: position }; + }); + } + + const unavailable = Boolean(disabled || sending); + const canSend = Boolean(draft.trim()) && !unavailable; + const state = disabled + ? "disabled" + : sending + ? "sending" + : error + ? "failed" + : "ready"; + + return ( +
+
+