mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
feat(desktop-new): evolve navigation and design system
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
7
desktop-new/src/app/routes/design/composer.tsx
Normal file
7
desktop-new/src/app/routes/design/composer.tsx
Normal file
@@ -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,
|
||||
});
|
||||
48
desktop-new/src/app/useKeyboardFocusVisibility.ts
Normal file
48
desktop-new/src/app/useKeyboardFocusVisibility.ts
Normal file
@@ -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;
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, ActivityStatus> = {
|
||||
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<string, AgentTurn>,
|
||||
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),
|
||||
),
|
||||
};
|
||||
}
|
||||
28
desktop-new/src/features/agent-activity/types.ts
Normal file
28
desktop-new/src/features/agent-activity/types.ts
Normal file
@@ -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[];
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="agent-activity-rail" role="status">
|
||||
{turns.map((turn) => {
|
||||
const current = latestActivityItem(turn);
|
||||
const label = `${turn.agentName} is working${
|
||||
current ? ` · ${current.label}` : ""
|
||||
}`;
|
||||
return (
|
||||
<span
|
||||
key={turn.key}
|
||||
className="agent-activity-rail-status text-body-sm"
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
desktop-new/src/features/agent-activity/useAgentActivity.ts
Normal file
47
desktop-new/src/features/agent-activity/useAgentActivity.ts
Normal file
@@ -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<Map<string, AgentTurn>>(
|
||||
() => 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
<span className="text-heading text-primary">Agents</span>
|
||||
<button
|
||||
type="button"
|
||||
className="navigator-row-action always-visible"
|
||||
className="navigation-item-action always-visible"
|
||||
onClick={onNew}
|
||||
aria-label="New agent"
|
||||
>
|
||||
@@ -45,7 +59,7 @@ export function AgentsNavigator({
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
type="button"
|
||||
className="agent-navigator-row"
|
||||
className="agent-navigation-item"
|
||||
data-selected={selectedPubkey === agent.pubkey || undefined}
|
||||
key={agent.pubkey}
|
||||
onClick={() => onSelect(agent.pubkey)}
|
||||
@@ -91,17 +105,13 @@ function AgentCreation({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [relayUrl, identity] = await Promise.all([
|
||||
runtime.relayUrl(),
|
||||
runtime.identity(),
|
||||
]);
|
||||
const scope = await readAgentScope();
|
||||
onCreated(
|
||||
await agentRuntime.create({
|
||||
name: name.trim(),
|
||||
instructions: instructions.trim(),
|
||||
runtime: selected,
|
||||
relayUrl,
|
||||
signerPubkey: identity.pubkey,
|
||||
...scope,
|
||||
}),
|
||||
);
|
||||
} catch (caught) {
|
||||
@@ -159,7 +169,7 @@ function AgentCreation({
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="text-body text-danger" role="alert">
|
||||
<p className="text-body text-red-12" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -198,15 +208,8 @@ function AgentDetail({
|
||||
if (agent.status === "running") {
|
||||
onChange(await agentRuntime.stop(agent.pubkey));
|
||||
} else {
|
||||
const [relayUrl, identity] = await Promise.all([
|
||||
runtime.relayUrl(),
|
||||
runtime.identity(),
|
||||
]);
|
||||
onChange(
|
||||
await agentRuntime.start(agent.pubkey, {
|
||||
relayUrl,
|
||||
signerPubkey: identity.pubkey,
|
||||
}),
|
||||
await agentRuntime.start(agent.pubkey, await readAgentScope()),
|
||||
);
|
||||
}
|
||||
} catch (caught) {
|
||||
@@ -263,7 +266,7 @@ function AgentDetail({
|
||||
</div>
|
||||
</section>
|
||||
{error || agent.lastError ? (
|
||||
<p className="setup-callout text-body text-danger" role="alert">
|
||||
<p className="setup-callout text-body text-red-12" role="alert">
|
||||
{error || agent.lastError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -114,7 +114,7 @@ export function ComposerSpikePage() {
|
||||
data-testid="insert-morgan"
|
||||
onMouseDown={keepFocus}
|
||||
onClick={() => insertChip(MORGAN)}
|
||||
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 Morgan
|
||||
</button>
|
||||
@@ -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
|
||||
</button>
|
||||
@@ -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
|
||||
</button>
|
||||
|
||||
91
desktop-new/src/features/composer/ui/EmojiPicker.tsx
Normal file
91
desktop-new/src/features/composer/ui/EmojiPicker.tsx
Normal file
@@ -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<HTMLTextAreaElement | null>;
|
||||
onInsert: (emoji: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<BasePopover.Root modal={false} open={open} onOpenChange={setOpen}>
|
||||
<BasePopover.Trigger
|
||||
render={
|
||||
<IconButton
|
||||
aria-label="Choose emoji"
|
||||
disabled={disabled}
|
||||
icon={<IconMoodSmile size={18} stroke={1.8} aria-hidden="true" />}
|
||||
variant="ghost"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<BasePopover.Portal>
|
||||
<BasePopover.Positioner side="top" align="start" sideOffset={8}>
|
||||
<BasePopover.Popup
|
||||
className="composer-emoji-picker"
|
||||
initialFocus={false}
|
||||
finalFocus={textarea}
|
||||
>
|
||||
<fieldset className="composer-emoji-grid">
|
||||
<legend className="sr-only">Emoji</legend>
|
||||
{EMOJI.map((emoji) => (
|
||||
<Button
|
||||
key={emoji}
|
||||
aria-label={`Insert ${emoji}`}
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onInsert(emoji);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">{emoji}</span>
|
||||
</Button>
|
||||
))}
|
||||
</fieldset>
|
||||
</BasePopover.Popup>
|
||||
</BasePopover.Positioner>
|
||||
</BasePopover.Portal>
|
||||
</BasePopover.Root>
|
||||
);
|
||||
}
|
||||
181
desktop-new/src/features/composer/ui/MessageComposer.tsx
Normal file
181
desktop-new/src/features/composer/ui/MessageComposer.tsx
Normal file
@@ -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<void>;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const textarea = useRef<HTMLTextAreaElement>(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 (
|
||||
<form className="message-composer" data-state={state} onSubmit={submit}>
|
||||
<div className="message-composer-input">
|
||||
<textarea
|
||||
ref={textarea}
|
||||
value={draft}
|
||||
rows={3}
|
||||
disabled={disabled}
|
||||
aria-label={placeholder}
|
||||
aria-describedby={error ? "composer-send-error" : undefined}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => {
|
||||
selection.current = {
|
||||
start: event.currentTarget.selectionStart,
|
||||
end: event.currentTarget.selectionEnd,
|
||||
};
|
||||
setError(null);
|
||||
onDraftChange(event.target.value);
|
||||
}}
|
||||
onSelect={rememberSelection}
|
||||
onKeyUp={rememberSelection}
|
||||
onMouseUp={rememberSelection}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error ? (
|
||||
<p
|
||||
id="composer-send-error"
|
||||
className="message-composer-feedback text-body-sm text-red-12"
|
||||
role="alert"
|
||||
>
|
||||
Couldn’t send. Your message is still here — try again.
|
||||
</p>
|
||||
) : sending ? (
|
||||
<p
|
||||
className="message-composer-feedback text-body-sm text-secondary"
|
||||
role="status"
|
||||
>
|
||||
Sending message…
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="message-composer-actions">
|
||||
<div className="message-composer-actions-start">
|
||||
<IconButton
|
||||
aria-label="Attach file"
|
||||
icon={<IconPaperclip size={18} stroke={1.8} aria-hidden="true" />}
|
||||
variant="ghost"
|
||||
/>
|
||||
<EmojiPicker
|
||||
disabled={unavailable}
|
||||
textarea={textarea}
|
||||
onInsert={insertEmoji}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Toggle formatting"
|
||||
icon={<IconTypography size={18} stroke={1.8} aria-hidden="true" />}
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
<div className="message-composer-actions-end">
|
||||
<IconButton
|
||||
aria-label="Record voice note"
|
||||
icon={<IconMicrophone size={18} stroke={1.8} aria-hidden="true" />}
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
type="submit"
|
||||
aria-label={sending ? "Sending message" : "Send message"}
|
||||
icon={<IconArrowUp size={18} stroke={1.8} aria-hidden="true" />}
|
||||
variant="solid"
|
||||
disabled={!canSend}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
44
desktop-new/src/features/composer/useComposerDrafts.ts
Normal file
44
desktop-new/src/features/composer/useComposerDrafts.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export type ComposerDrafts = {
|
||||
readDraft: (destinationId: string) => string;
|
||||
writeDraft: (destinationId: string, value: string) => void;
|
||||
clearDraft: (destinationId: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns drafts by product destination rather than by a mounted text field.
|
||||
*
|
||||
* The AppShell creates this once for the current application composition, so a
|
||||
* conversation can unmount while its draft remains ready when the person
|
||||
* returns. Persisting across an application restart is a later product
|
||||
* decision; this controller deliberately does not invent it.
|
||||
*/
|
||||
export function useComposerDrafts(): ComposerDrafts {
|
||||
const [drafts, setDrafts] = useState<Map<string, string>>(() => new Map());
|
||||
|
||||
const readDraft = useCallback(
|
||||
(destinationId: string) => drafts.get(destinationId) ?? "",
|
||||
[drafts],
|
||||
);
|
||||
|
||||
const writeDraft = useCallback((destinationId: string, value: string) => {
|
||||
setDrafts((current) => {
|
||||
const next = new Map(current);
|
||||
if (value) next.set(destinationId, value);
|
||||
else next.delete(destinationId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearDraft = useCallback((destinationId: string) => {
|
||||
setDrafts((current) => {
|
||||
if (!current.has(destinationId)) return current;
|
||||
const next = new Map(current);
|
||||
next.delete(destinationId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { readDraft, writeDraft, clearDraft };
|
||||
}
|
||||
96
desktop-new/src/features/conversation/channelWindow.test.ts
Normal file
96
desktop-new/src/features/conversation/channelWindow.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mergeMessages, projectChannelWindow } from "./channelWindow";
|
||||
|
||||
const event = (
|
||||
kind: number,
|
||||
id: string,
|
||||
content = "content",
|
||||
createdAt = 100,
|
||||
) => ({
|
||||
id,
|
||||
pubkey: "author",
|
||||
content,
|
||||
created_at: createdAt,
|
||||
kind,
|
||||
tags:
|
||||
kind === 39006
|
||||
? [
|
||||
["h", "channel"],
|
||||
["d", "channel:head"],
|
||||
]
|
||||
: [["h", "channel"]],
|
||||
});
|
||||
|
||||
describe("projectChannelWindow", () => {
|
||||
it("keeps protocol overlays and system events out of the transcript", () => {
|
||||
const projection = projectChannelWindow(
|
||||
[
|
||||
event(40099, "system", '{"type":"channel_created"}'),
|
||||
event(9, "message", "Hello"),
|
||||
event(39005, "summary", '{"reply_count":2}'),
|
||||
event(39006, "bounds", '{"has_more":false,"next_cursor":null}'),
|
||||
],
|
||||
{ channelId: "channel" },
|
||||
);
|
||||
|
||||
expect(projection.messages.map(({ id }) => id)).toEqual(["message"]);
|
||||
expect(projection.systemEvents.map(({ id }) => id)).toEqual(["system"]);
|
||||
expect(projection.threadSummaries.map(({ id }) => id)).toEqual(["summary"]);
|
||||
expect(projection.bounds).toEqual({ hasMore: false, nextCursor: null });
|
||||
});
|
||||
|
||||
it("rejects ambiguous or internally inconsistent bounds", () => {
|
||||
expect(() =>
|
||||
projectChannelWindow(
|
||||
[
|
||||
event(39006, "one", '{"has_more":false,"next_cursor":null}'),
|
||||
event(39006, "two", '{"has_more":false,"next_cursor":null}'),
|
||||
],
|
||||
{ channelId: "channel" },
|
||||
),
|
||||
).toThrow("more than one bounds");
|
||||
expect(() =>
|
||||
projectChannelWindow(
|
||||
[event(39006, "bounds", '{"has_more":true,"next_cursor":null}')],
|
||||
{ channelId: "channel" },
|
||||
),
|
||||
).toThrow("require next_cursor");
|
||||
expect(() =>
|
||||
projectChannelWindow([event(9, "message")], { channelId: "channel" }),
|
||||
).toThrow("missing bounds");
|
||||
});
|
||||
|
||||
it("uses the event ID as the same-second ordering tie-breaker", () => {
|
||||
const projection = projectChannelWindow(
|
||||
[
|
||||
event(9, "b", "second", 100),
|
||||
event(9, "a", "first", 100),
|
||||
event(39006, "bounds", '{"has_more":false,"next_cursor":null}'),
|
||||
],
|
||||
{ channelId: "channel" },
|
||||
);
|
||||
|
||||
expect(projection.messages.map(({ id }) => id)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeMessages", () => {
|
||||
it("deduplicates the live overlay and keeps total relay order", () => {
|
||||
const result = mergeMessages(
|
||||
[
|
||||
{ ...event(9, "b", "b", 100), createdAt: 100 },
|
||||
{ ...event(9, "c", "c", 101), createdAt: 101 },
|
||||
],
|
||||
[
|
||||
{ ...event(9, "a", "a", 100), createdAt: 100 },
|
||||
{ ...event(9, "c", "fresh c", 101), createdAt: 101 },
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.map(({ id, content }) => [id, content])).toEqual([
|
||||
["a", "a"],
|
||||
["b", "b"],
|
||||
["c", "fresh c"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
128
desktop-new/src/features/conversation/channelWindow.ts
Normal file
128
desktop-new/src/features/conversation/channelWindow.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Message } from "@/features/sessions/types";
|
||||
import type {
|
||||
ChannelWindowCursor,
|
||||
ChannelWindowRequest,
|
||||
RawChannelEvent,
|
||||
} from "@/shared/runtime/channelWindow";
|
||||
|
||||
export const CHANNEL_MESSAGE_KINDS = new Set([9, 40002, 40008]);
|
||||
export const CHANNEL_SYSTEM_KIND = 40099;
|
||||
export const THREAD_SUMMARY_KIND = 39005;
|
||||
export const WINDOW_BOUNDS_KIND = 39006;
|
||||
|
||||
export type ChannelWindowBounds = {
|
||||
hasMore: boolean;
|
||||
nextCursor: ChannelWindowCursor | null;
|
||||
};
|
||||
|
||||
export type ConversationWindowRequest = ChannelWindowRequest;
|
||||
|
||||
export type ChannelWindow = {
|
||||
messages: Message[];
|
||||
systemEvents: RawChannelEvent[];
|
||||
threadSummaries: RawChannelEvent[];
|
||||
bounds: ChannelWindowBounds | null;
|
||||
};
|
||||
|
||||
function compareMessages(left: Message, right: Message) {
|
||||
return left.createdAt - right.createdAt || left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function expectedBoundsKey({ channelId, cursor }: ConversationWindowRequest) {
|
||||
return `${channelId}:${cursor ? `${cursor.createdAt}:${cursor.id}` : "head"}`;
|
||||
}
|
||||
|
||||
function parseBounds(
|
||||
event: RawChannelEvent,
|
||||
request: ConversationWindowRequest,
|
||||
): ChannelWindowBounds {
|
||||
const requestKey = event.tags.find(([name]) => name === "d")?.[1];
|
||||
if (requestKey !== expectedBoundsKey(request)) {
|
||||
throw new Error("Channel window bounds do not match their request.");
|
||||
}
|
||||
let content: unknown;
|
||||
try {
|
||||
content = JSON.parse(event.content);
|
||||
} catch {
|
||||
throw new Error("Channel window bounds are not valid JSON.");
|
||||
}
|
||||
if (typeof content !== "object" || content === null) {
|
||||
throw new Error("Channel window bounds are not an object.");
|
||||
}
|
||||
const { has_more: hasMore, next_cursor: nextCursor } = content as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (typeof hasMore !== "boolean") {
|
||||
throw new Error("Channel window bounds are missing has_more.");
|
||||
}
|
||||
if (nextCursor === null) {
|
||||
if (hasMore) throw new Error("Channel window bounds require next_cursor.");
|
||||
return { hasMore, nextCursor: null };
|
||||
}
|
||||
if (typeof nextCursor !== "object" || nextCursor === null) {
|
||||
throw new Error("Channel window next_cursor is invalid.");
|
||||
}
|
||||
const { created_at: createdAt, id } = nextCursor as Record<string, unknown>;
|
||||
if (
|
||||
typeof createdAt !== "number" ||
|
||||
!Number.isInteger(createdAt) ||
|
||||
typeof id !== "string"
|
||||
) {
|
||||
throw new Error("Channel window next_cursor is invalid.");
|
||||
}
|
||||
if (!hasMore)
|
||||
throw new Error("Channel window bounds must not have next_cursor.");
|
||||
return { hasMore, nextCursor: { createdAt, id } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions the relay's channel-window response before any ordering logic.
|
||||
* Bounds and thread summaries describe rows; they never become rows themselves.
|
||||
*/
|
||||
export function projectChannelWindow(
|
||||
events: RawChannelEvent[],
|
||||
request: ConversationWindowRequest,
|
||||
): ChannelWindow {
|
||||
const messages: Message[] = [];
|
||||
const systemEvents: RawChannelEvent[] = [];
|
||||
const threadSummaries: RawChannelEvent[] = [];
|
||||
let bounds: ChannelWindowBounds | null = null;
|
||||
|
||||
for (const event of events) {
|
||||
if (CHANNEL_MESSAGE_KINDS.has(event.kind)) {
|
||||
messages.push({
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
content: event.content,
|
||||
createdAt: event.created_at,
|
||||
kind: event.kind,
|
||||
tags: event.tags,
|
||||
});
|
||||
} else if (event.kind === CHANNEL_SYSTEM_KIND) {
|
||||
systemEvents.push(event);
|
||||
} else if (event.kind === THREAD_SUMMARY_KIND) {
|
||||
threadSummaries.push(event);
|
||||
} else if (event.kind === WINDOW_BOUNDS_KIND) {
|
||||
if (bounds)
|
||||
throw new Error("Channel window returned more than one bounds event.");
|
||||
bounds = parseBounds(event, request);
|
||||
}
|
||||
}
|
||||
|
||||
if (!bounds) {
|
||||
// An accessible channel window always carries exactly one bounds overlay.
|
||||
// Without it the client cannot honestly know whether history is exhausted,
|
||||
// so it must not turn a malformed/degraded response into a plausible page.
|
||||
throw new Error("Channel window response is missing bounds.");
|
||||
}
|
||||
|
||||
messages.sort(compareMessages);
|
||||
return { messages, systemEvents, threadSummaries, bounds };
|
||||
}
|
||||
|
||||
export function mergeMessages(history: Message[], live: Message[]): Message[] {
|
||||
const byId = new Map(history.map((message) => [message.id, message]));
|
||||
for (const message of live) byId.set(message.id, message);
|
||||
return [...byId.values()].sort(compareMessages);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react";
|
||||
|
||||
import { latestActivityItem } from "@/features/agent-activity/activityProjection";
|
||||
import type { AgentTurn } from "@/features/agent-activity/types";
|
||||
import { MessageComposer } from "@/features/composer/ui/MessageComposer";
|
||||
|
||||
/**
|
||||
* The conversation's fixed-height bottom dock.
|
||||
*
|
||||
* Its grid owns the spatial relationship between authoring and activity. When
|
||||
* activity arrives, it takes its natural row below the Composer; the Composer
|
||||
* yields only from its lower edge while its top stays fixed.
|
||||
*/
|
||||
export function ConversationComposerDock({
|
||||
draft,
|
||||
onDraftChange,
|
||||
onSend,
|
||||
turns,
|
||||
}: {
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
onSend: (content: string) => Promise<void>;
|
||||
turns: AgentTurn[];
|
||||
}) {
|
||||
const workingTurns = turns.filter(
|
||||
(turn) => turn.status === "pending" || turn.status === "running",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="conversation-composer-dock"
|
||||
data-activity={workingTurns.length > 0 || undefined}
|
||||
>
|
||||
<MessageComposer
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
onSend={onSend}
|
||||
/>
|
||||
{workingTurns.length > 0 ? (
|
||||
<div className="conversation-activity-rail" role="status">
|
||||
{workingTurns.map((turn) => {
|
||||
const current = latestActivityItem(turn);
|
||||
return (
|
||||
<div className="conversation-activity-rail-item" key={turn.key}>
|
||||
<IconLoader2
|
||||
className="animate-spin"
|
||||
size={15}
|
||||
stroke={1.7}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 text-body-sm text-secondary">
|
||||
<span className="font-semibold text-primary">
|
||||
{turn.agentName}
|
||||
</span>{" "}
|
||||
is working{current ? ` · ${current.label}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
desktop-new/src/features/conversation/useConversation.ts
Normal file
203
desktop-new/src/features/conversation/useConversation.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
Channel,
|
||||
Identity,
|
||||
Message,
|
||||
Participant,
|
||||
} from "@/features/sessions/types";
|
||||
import { runtime } from "@/shared/runtime/client";
|
||||
import { mergeMessages, projectChannelWindow } from "./channelWindow";
|
||||
|
||||
type SendOperation = {
|
||||
localId: string;
|
||||
content: string;
|
||||
message: Message;
|
||||
};
|
||||
|
||||
function messageError(caught: unknown) {
|
||||
return caught instanceof Error ? caught.message : String(caught);
|
||||
}
|
||||
|
||||
/**
|
||||
* The behavioral owner of one shared conversation.
|
||||
*
|
||||
* History is fetched through the relay's window contract. Live delivery is a
|
||||
* separate overlay, reconciled with a fresh head read after subscription setup
|
||||
* settles so the interval between the two cannot lose a message.
|
||||
*/
|
||||
export function useConversation(channel: Channel, identity: Identity) {
|
||||
const generation = useRef(0);
|
||||
const [history, setHistory] = useState<Message[]>([]);
|
||||
const [live, setLive] = useState<Message[]>([]);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [operations, setOperations] = useState<SendOperation[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadHead = useCallback(
|
||||
async (expectedGeneration: number) => {
|
||||
const [window, nextParticipants] = await Promise.all([
|
||||
runtime
|
||||
.channelWindow({ channelId: channel.id })
|
||||
.then((events) =>
|
||||
projectChannelWindow(events, { channelId: channel.id }),
|
||||
),
|
||||
runtime.participants(channel.id),
|
||||
]);
|
||||
if (generation.current !== expectedGeneration) return;
|
||||
setHistory(window.messages);
|
||||
setParticipants(nextParticipants);
|
||||
setError(null);
|
||||
},
|
||||
[channel.id],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const expectedGeneration = generation.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
await loadHead(expectedGeneration);
|
||||
} catch (caught) {
|
||||
if (generation.current === expectedGeneration)
|
||||
setError(messageError(caught));
|
||||
} finally {
|
||||
if (generation.current === expectedGeneration) setLoading(false);
|
||||
}
|
||||
}, [loadHead]);
|
||||
|
||||
useEffect(() => {
|
||||
const expectedGeneration = generation.current + 1;
|
||||
generation.current = expectedGeneration;
|
||||
let disposed = false;
|
||||
let stop: (() => void) | undefined;
|
||||
|
||||
setHistory([]);
|
||||
setLive([]);
|
||||
setParticipants([]);
|
||||
setOperations([]);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const settleInitialLoad = async () => {
|
||||
try {
|
||||
await loadHead(expectedGeneration);
|
||||
} catch (caught) {
|
||||
if (!disposed && generation.current === expectedGeneration) {
|
||||
setError(messageError(caught));
|
||||
}
|
||||
} finally {
|
||||
if (!disposed && generation.current === expectedGeneration) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void settleInitialLoad();
|
||||
void runtime
|
||||
.subscribeMessages(channel.id, (incoming) => {
|
||||
if (disposed || generation.current !== expectedGeneration) return;
|
||||
setLive((current) => mergeMessages(current, [incoming]));
|
||||
})
|
||||
.then(
|
||||
(cleanup) => {
|
||||
if (disposed) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
stop = cleanup;
|
||||
// The live subscription begins at now. Re-read the head after it is
|
||||
// armed to close the history/subscription establishment interval.
|
||||
return loadHead(expectedGeneration).catch((caught) => {
|
||||
if (!disposed && generation.current === expectedGeneration) {
|
||||
setError(`Live updates unavailable: ${messageError(caught)}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
(caught) => {
|
||||
if (!disposed && generation.current === expectedGeneration) {
|
||||
setError(`Live updates unavailable: ${messageError(caught)}`);
|
||||
}
|
||||
// The head is still authoritative when the live socket is not.
|
||||
return loadHead(expectedGeneration).catch(() => undefined);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
generation.current += 1;
|
||||
stop?.();
|
||||
};
|
||||
}, [channel.id, loadHead]);
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const accepted = mergeMessages(history, live);
|
||||
const pending = operations.map(({ message }) => message);
|
||||
return [...accepted, ...pending].sort(
|
||||
(left, right) =>
|
||||
left.createdAt - right.createdAt || left.id.localeCompare(right.id),
|
||||
);
|
||||
}, [history, live, operations]);
|
||||
|
||||
const send = useCallback(
|
||||
async (content: string, retryLocalId?: string) => {
|
||||
const expectedGeneration = generation.current;
|
||||
const existing = retryLocalId
|
||||
? operations.find((operation) => operation.localId === retryLocalId)
|
||||
: undefined;
|
||||
const localId = existing?.localId ?? `pending-${crypto.randomUUID()}`;
|
||||
const payload = existing?.content ?? content;
|
||||
const pending: Message = {
|
||||
id: localId,
|
||||
pubkey: identity.pubkey,
|
||||
content: payload,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
kind: 9,
|
||||
tags: [["h", channel.id]],
|
||||
pending: navigator.onLine ? "sending" : "waiting",
|
||||
};
|
||||
setOperations((current) => [
|
||||
...current.filter((operation) => operation.localId !== localId),
|
||||
{ localId, content: payload, message: pending },
|
||||
]);
|
||||
|
||||
try {
|
||||
const accepted = await runtime.sendMessage(channel.id, payload);
|
||||
if (generation.current !== expectedGeneration) return;
|
||||
setOperations((current) =>
|
||||
current.filter((operation) => operation.localId !== localId),
|
||||
);
|
||||
setLive((current) => mergeMessages(current, [accepted]));
|
||||
} catch (caught) {
|
||||
if (generation.current !== expectedGeneration) return;
|
||||
setOperations((current) =>
|
||||
current.map((operation) =>
|
||||
operation.localId === localId
|
||||
? {
|
||||
...operation,
|
||||
message: {
|
||||
...operation.message,
|
||||
pending: "failed",
|
||||
error: messageError(caught),
|
||||
},
|
||||
}
|
||||
: operation,
|
||||
),
|
||||
);
|
||||
throw caught;
|
||||
}
|
||||
},
|
||||
[channel.id, identity.pubkey, operations],
|
||||
);
|
||||
|
||||
const retrySend = useCallback(
|
||||
async (localId: string) => {
|
||||
const operation = operations.find(
|
||||
(current) => current.localId === localId,
|
||||
);
|
||||
if (operation) await send(operation.content, localId);
|
||||
},
|
||||
[operations, send],
|
||||
);
|
||||
|
||||
return { messages, participants, loading, error, refresh, send, retrySend };
|
||||
}
|
||||
@@ -186,7 +186,7 @@ export function BaseUiPage() {
|
||||
title="The count"
|
||||
description="Stated so the balance is visible rather than counted by hand each time someone asks."
|
||||
>
|
||||
<dl className="flex flex-wrap gap-x-12 gap-y-4 rounded-xl bg-inset px-5 py-4">
|
||||
<dl className="flex flex-wrap gap-x-12 gap-y-4 rounded-xl bg-neutral-2 px-5 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-body-sm text-tertiary">Backed by Base UI</dt>
|
||||
<dd className="text-body text-primary">
|
||||
|
||||
@@ -3,14 +3,21 @@ import { COMPONENTS } from "@/shared/ui/registry";
|
||||
import { COMPONENT_SPECIMENS } from "./componentSpecimens";
|
||||
|
||||
export function ComponentsPage() {
|
||||
const topLevelComponents = COMPONENTS.filter(
|
||||
(component) => component.parent === undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="component-page-heading">
|
||||
<h1 className="text-title text-primary">Components</h1>
|
||||
</header>
|
||||
<div className="component-overview-grid">
|
||||
{COMPONENTS.map((component) => {
|
||||
{topLevelComponents.map((component) => {
|
||||
const Specimen = COMPONENT_SPECIMENS[component.slug];
|
||||
const children = COMPONENTS.filter(
|
||||
(candidate) => candidate.parent === component.slug,
|
||||
);
|
||||
return (
|
||||
<article key={component.slug} className="component-overview-item">
|
||||
<div className="component-overview-preview">
|
||||
@@ -23,6 +30,20 @@ export function ComponentsPage() {
|
||||
>
|
||||
{component.name}
|
||||
</Link>
|
||||
{children.length > 0 ? (
|
||||
<nav aria-label={`${component.name} components`}>
|
||||
{children.map((child) => (
|
||||
<Link
|
||||
key={child.slug}
|
||||
to="/design/components/$component"
|
||||
params={{ component: child.slug }}
|
||||
className="text-body-sm text-secondary hover:text-primary"
|
||||
>
|
||||
{child.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
79
desktop-new/src/features/design-system/ui/ComposerPage.tsx
Normal file
79
desktop-new/src/features/design-system/ui/ComposerPage.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { ConversationComposerDock } from "@/features/conversation/ui/ConversationComposerDock";
|
||||
import type { AgentTurn } from "@/features/agent-activity/types";
|
||||
|
||||
const WORKING_TURN: AgentTurn = {
|
||||
key: "design:vogue:session:turn",
|
||||
agentPubkey: "vogue",
|
||||
agentName: "Vogue",
|
||||
sessionId: "session",
|
||||
turnId: "turn",
|
||||
channelId: "design",
|
||||
status: "running",
|
||||
items: [
|
||||
{
|
||||
id: "review",
|
||||
label: "Reviewing the interaction plan",
|
||||
status: "running",
|
||||
timestamp: "2026-09-08T03:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Product-composition documentation for the Conversation Composer.
|
||||
*
|
||||
* This is not a shared UI component API. It renders the actual dock and
|
||||
* composer so the page remains a visual contract for the product composition
|
||||
* as its real authoring capabilities arrive.
|
||||
*/
|
||||
function ComposerSpecimen({ turns = [] }: { turns?: AgentTurn[] }) {
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
return (
|
||||
<div className="composer-page-specimen">
|
||||
<ConversationComposerDock
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onSend={async () => undefined}
|
||||
turns={turns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComposerPage() {
|
||||
return (
|
||||
<>
|
||||
<header className="component-page-heading">
|
||||
<h1 className="text-title text-primary">Composer</h1>
|
||||
<p className="max-w-prose text-body text-secondary">
|
||||
The conversation authoring surface. Context arrives above when it
|
||||
changes the contribution; people, agents, and channels stay inline in
|
||||
the authored text; activity makes room beneath without moving the
|
||||
Composer’s top edge.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="component-specimen-stack">
|
||||
<section className="component-specimen-group">
|
||||
<h2 className="text-body-sm text-tertiary">Writing</h2>
|
||||
<ComposerSpecimen />
|
||||
</section>
|
||||
|
||||
<section className="component-specimen-group">
|
||||
<h2 className="text-body-sm text-tertiary">
|
||||
Working alongside an agent
|
||||
</h2>
|
||||
<p className="max-w-prose text-body-sm text-secondary">
|
||||
The activity rail takes only its own height plus a 4px gap from the
|
||||
Composer’s bottom edge. The Composer keeps its top edge and remains
|
||||
usable while an agent works.
|
||||
</p>
|
||||
<ComposerSpecimen turns={[WORKING_TURN]} />
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,22 @@ import { useColorScheme } from "@/shared/theme/useColorScheme";
|
||||
import { COMPONENTS } from "@/shared/ui/registry";
|
||||
|
||||
/** A nav entry, optionally with children shown indented beneath it. */
|
||||
type NavItem = [label: string, to: string, children?: Array<[string, string]>];
|
||||
type NavItem = [label: string, to: string, children?: NavItem[]];
|
||||
|
||||
function componentNavItems(parent?: string): NavItem[] {
|
||||
return COMPONENTS.filter((component) => component.parent === parent).map(
|
||||
(component) => [
|
||||
component.name,
|
||||
`/design/components/${component.slug}`,
|
||||
componentNavItems(component.slug),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const COMPONENT_NAV_ITEMS: NavItem[] = [
|
||||
["Overview", "/design/components", componentNavItems()],
|
||||
["Base UI backing", "/design/components/base-ui"],
|
||||
];
|
||||
|
||||
const SECTIONS: Array<{ heading: string; items: NavItem[] }> = [
|
||||
{
|
||||
@@ -23,6 +38,10 @@ const SECTIONS: Array<{ heading: string; items: NavItem[] }> = [
|
||||
["Motion", "/design/motion"],
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Product compositions",
|
||||
items: [["Composer", "/design/composer"]],
|
||||
},
|
||||
{
|
||||
heading: "System",
|
||||
items: [
|
||||
@@ -32,17 +51,7 @@ const SECTIONS: Array<{ heading: string; items: NavItem[] }> = [
|
||||
},
|
||||
{
|
||||
heading: "Components",
|
||||
items: [
|
||||
[
|
||||
"Overview",
|
||||
"/design/components",
|
||||
COMPONENTS.map((component) => [
|
||||
component.name,
|
||||
`/design/components/${component.slug}`,
|
||||
]),
|
||||
],
|
||||
["Base UI backing", "/design/components/base-ui"],
|
||||
],
|
||||
items: COMPONENT_NAV_ITEMS,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -71,6 +80,28 @@ function NavLink({
|
||||
);
|
||||
}
|
||||
|
||||
function NavItems({ items, depth = 0 }: { items: NavItem[]; depth?: number }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 lg:flex-col">
|
||||
{items.map(([label, to, children]) => (
|
||||
<Fragment key={to}>
|
||||
<div className={depth === 0 ? undefined : "lg:pl-3"}>
|
||||
<NavLink
|
||||
to={to}
|
||||
exact={children !== undefined && children.length > 0}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
</div>
|
||||
{children && children.length > 0 ? (
|
||||
<NavItems items={children} depth={depth + 1} />
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignSystemLayout() {
|
||||
const { scheme, toggle } = useColorScheme();
|
||||
|
||||
@@ -104,25 +135,7 @@ export function DesignSystemLayout() {
|
||||
time, as the product repeats something.
|
||||
</p>
|
||||
) : (
|
||||
/* Narrow: links wrap as a row so the nav costs a few lines
|
||||
instead of a screen. From lg they return to a column. */
|
||||
<div className="flex flex-wrap gap-1 lg:flex-col">
|
||||
{section.items.map(([label, to, children]) => (
|
||||
<Fragment key={to}>
|
||||
<NavLink to={to} exact={children !== undefined}>
|
||||
{label}
|
||||
</NavLink>
|
||||
{/* Indented on the side rail so the nesting reads;
|
||||
inline in the wrapped narrow row, where an indent
|
||||
would just look like a gap. */}
|
||||
{children?.map(([childLabel, childTo]) => (
|
||||
<div key={childTo} className="lg:pl-3">
|
||||
<NavLink to={childTo}>{childLabel}</NavLink>
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<NavItems items={section.items} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -132,7 +145,7 @@ export function DesignSystemLayout() {
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={`Switch to ${scheme === "light" ? "dark" : "light"} mode`}
|
||||
className="mx-3 self-start rounded-lg bg-inset px-3 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
|
||||
className="mx-3 self-start rounded-lg bg-neutral-2 px-3 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
|
||||
>
|
||||
{scheme === "light" ? "Dark mode" : "Light mode"}
|
||||
</button>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function GrowthPage() {
|
||||
title="The procedure"
|
||||
description="Runs per change, by whoever needs the value. Nothing here needs permission, and every addition arrives in the same change that needed it, carrying its values, its description, and its owner."
|
||||
>
|
||||
<ol className="flex flex-col gap-2 rounded-xl bg-inset px-5 py-4">
|
||||
<ol className="flex flex-col gap-2 rounded-xl bg-neutral-2 px-5 py-4">
|
||||
{GROWTH_PROCEDURE.map((step, index) => (
|
||||
<li key={step} className="flex gap-3">
|
||||
<span className="w-4 shrink-0 text-body text-purple-12">
|
||||
@@ -64,7 +64,7 @@ export function GrowthPage() {
|
||||
title="The audit"
|
||||
description="Runs on a schedule rather than per change. It reports rather than silently rewrites, except where the fix is unambiguous. Growth without pruning is how a system accumulates thirteen transparencies of one colour."
|
||||
>
|
||||
<div className="rounded-xl bg-inset px-5">
|
||||
<div className="rounded-xl bg-neutral-2 px-5">
|
||||
{AUDIT.map(([check, action]) => (
|
||||
<div
|
||||
key={check}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function OverviewPage() {
|
||||
title="The layers"
|
||||
description="Only the role layer is ever used when building a screen. That separation is what lets the entire look change without editing a component."
|
||||
>
|
||||
<div className="flex flex-col gap-2 rounded-xl bg-inset px-6 py-5">
|
||||
<div className="flex flex-col gap-2 rounded-xl bg-neutral-2 px-6 py-5">
|
||||
{LAYERS.map(([layer, what, why]) => (
|
||||
<div key={layer} className="flex flex-wrap items-baseline gap-x-4">
|
||||
<span className="w-16 shrink-0 text-body text-tertiary">
|
||||
@@ -64,19 +64,19 @@ export function OverviewPage() {
|
||||
</Link>
|
||||
<Link
|
||||
to="/design/typography"
|
||||
className="rounded-lg bg-inset px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
|
||||
className="rounded-lg bg-neutral-2 px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
|
||||
>
|
||||
Typography
|
||||
</Link>
|
||||
<Link
|
||||
to="/design/glass"
|
||||
className="rounded-lg bg-inset px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
|
||||
className="rounded-lg bg-neutral-2 px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
|
||||
>
|
||||
Glass
|
||||
</Link>
|
||||
<Link
|
||||
to="/design/growth"
|
||||
className="rounded-lg bg-inset px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
|
||||
className="rounded-lg bg-neutral-2 px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
|
||||
>
|
||||
Growing the system
|
||||
</Link>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function VocabularyPage() {
|
||||
/>
|
||||
|
||||
<Section title="The words">
|
||||
<div className="flex flex-col gap-0 rounded-xl bg-inset px-5 py-2">
|
||||
<div className="flex flex-col gap-0 rounded-xl bg-neutral-2 px-5 py-2">
|
||||
{VOCABULARY.map((group) => (
|
||||
<div
|
||||
key={group.group}
|
||||
@@ -28,7 +28,7 @@ export function VocabularyPage() {
|
||||
{group.words.map((word) => (
|
||||
<code
|
||||
key={word}
|
||||
className="rounded bg-inset px-1.5 py-0.5 text-body-sm text-primary"
|
||||
className="rounded bg-neutral-2 px-1.5 py-0.5 text-body-sm text-primary"
|
||||
>
|
||||
{word}
|
||||
</code>
|
||||
@@ -64,7 +64,7 @@ export function VocabularyPage() {
|
||||
).map((group) => (
|
||||
<div
|
||||
key={group.label}
|
||||
className="flex-1 rounded-xl bg-inset px-5 py-4"
|
||||
className="flex-1 rounded-xl bg-neutral-2 px-5 py-4"
|
||||
>
|
||||
<p className="mb-1.5 text-body text-primary">{group.label}</p>
|
||||
{group.examples.map((example) => (
|
||||
|
||||
@@ -20,15 +20,14 @@ describe("the Base UI backing sentence", () => {
|
||||
expect(sentenceFor("button")).toBe("Built on Base UI Button.");
|
||||
expect(sentenceFor("avatar")).toBe("Built on Base UI Avatar.");
|
||||
expect(sentenceFor("tabs")).toBe("Built on Base UI Tabs.");
|
||||
expect(sentenceFor("navigation-item")).toBe("Built on Base UI Button.");
|
||||
expect(sentenceFor("preview-card")).toBe("Built on Base UI Preview Card.");
|
||||
});
|
||||
|
||||
it("says where an indirect part enters, rather than claiming none", () => {
|
||||
expect(sentenceFor("icon-button")).toBe(
|
||||
"No Base UI part of its own. Inherits Base UI Button through Buzz Button.",
|
||||
);
|
||||
expect(sentenceFor("navigator-row")).toBe(
|
||||
"No Base UI part of its own. Inherits Base UI Button through Buzz Button.",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads as one sentence when a component has both", () => {
|
||||
@@ -42,7 +41,7 @@ describe("the Base UI backing sentence", () => {
|
||||
"No Base UI part. Semantic native header.",
|
||||
);
|
||||
expect(sentenceFor("inline-chip")).toBe(
|
||||
"No Base UI part. Semantic native button or image role.",
|
||||
"No Base UI part of its own. Inherits Base UI Preview Card through Buzz PreviewCard.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -64,5 +63,11 @@ describe("the Base UI backing sentence", () => {
|
||||
.filter((segment) => segment.kind === "part")
|
||||
.map((segment) => (segment.kind === "part" ? segment.part.name : ""));
|
||||
expect(linked).toEqual(["Field", "Input", "Button"]);
|
||||
|
||||
expect(
|
||||
baseUiBackingSentence("inline-chip", "unused")
|
||||
.filter((segment) => segment.kind === "part")
|
||||
.map((segment) => (segment.kind === "part" ? segment.part.name : "")),
|
||||
).toEqual(["Preview Card"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,14 +11,15 @@ import { Avatar } from "@/shared/ui/Avatar";
|
||||
import { InlineChip } from "@/shared/ui/InlineChip";
|
||||
import { Button } from "@/shared/ui/Button";
|
||||
import { IconButton } from "@/shared/ui/IconButton";
|
||||
import { NavigatorRow } from "@/shared/ui/NavigatorRow";
|
||||
import { NavigatorSection } from "@/shared/ui/NavigatorSection";
|
||||
import { NavigationItem } from "@/shared/ui/NavigationItem";
|
||||
import { NavigationSection } from "@/shared/ui/NavigationSection";
|
||||
import { PanelHeader } from "@/shared/ui/PanelHeader";
|
||||
import { SearchField } from "@/shared/ui/SearchField";
|
||||
import { Tabs } from "@/shared/ui/Tabs";
|
||||
|
||||
import { ComponentAnatomy } from "./ComponentAnatomy";
|
||||
import { Panel } from "@/shared/ui/Panel";
|
||||
import { PreviewCard } from "@/shared/ui/PreviewCard";
|
||||
import type { ChipAddress } from "@/shared/chips/address";
|
||||
import { chipFaces } from "@/shared/chips/faceResolver";
|
||||
|
||||
@@ -498,52 +499,52 @@ function SearchFieldSpecimen() {
|
||||
);
|
||||
}
|
||||
|
||||
function NavigatorSectionSpecimen() {
|
||||
function NavigationSectionSpecimen() {
|
||||
return (
|
||||
<div className="component-specimen-stack">
|
||||
<SpecimenGroup label='label="Pinned", with rows as children'>
|
||||
<div className="component-navigator-section-demo">
|
||||
<NavigatorSection label="Pinned">
|
||||
<NavigatorRow
|
||||
<div className="component-navigation-section-demo">
|
||||
<NavigationSection label="Pinned">
|
||||
<NavigationItem
|
||||
label="buzz-design"
|
||||
icon={<IconHash size={16} stroke={1.7} aria-hidden="true" />}
|
||||
/>
|
||||
<NavigatorRow
|
||||
<NavigationItem
|
||||
label="desktop-new"
|
||||
icon={<IconHash size={16} stroke={1.7} aria-hidden="true" />}
|
||||
/>
|
||||
</NavigatorSection>
|
||||
</NavigationSection>
|
||||
</div>
|
||||
</SpecimenGroup>
|
||||
<SpecimenGroup label="Two sections adjacent — the gap between them is the grouping">
|
||||
<div className="component-navigator-section-demo">
|
||||
<NavigatorSection label="Projects">
|
||||
<NavigatorRow label="berd-main" />
|
||||
</NavigatorSection>
|
||||
<NavigatorSection label="Personal">
|
||||
<NavigatorRow label="design-system" />
|
||||
</NavigatorSection>
|
||||
<div className="component-navigation-section-demo">
|
||||
<NavigationSection label="Projects">
|
||||
<NavigationItem label="berd-main" />
|
||||
</NavigationSection>
|
||||
<NavigationSection label="Personal">
|
||||
<NavigationItem label="design-system" />
|
||||
</NavigationSection>
|
||||
</div>
|
||||
</SpecimenGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigatorRowSpecimen() {
|
||||
function NavigationItemSpecimen() {
|
||||
const [selected, setSelected] = useState("buzz-design");
|
||||
return (
|
||||
<div className="component-specimen-stack">
|
||||
{/* Interactive: clicking moves `selected`, so the selected fill and the
|
||||
unselected rest state are both visible at once and comparable. */}
|
||||
<SpecimenGroup label="Selected, and trailing for metadata — click to move the selection">
|
||||
<div className="component-navigator-section-demo">
|
||||
<NavigatorRow
|
||||
<div className="component-navigation-section-demo">
|
||||
<NavigationItem
|
||||
label="buzz-design"
|
||||
icon={<IconHash size={16} stroke={1.7} aria-hidden="true" />}
|
||||
selected={selected === "buzz-design"}
|
||||
onClick={() => setSelected("buzz-design")}
|
||||
/>
|
||||
<NavigatorRow
|
||||
<NavigationItem
|
||||
label="desktop-new"
|
||||
icon={<IconHash size={16} stroke={1.7} aria-hidden="true" />}
|
||||
trailing={<span className="text-body-sm">3</span>}
|
||||
@@ -553,8 +554,8 @@ function NavigatorRowSpecimen() {
|
||||
</div>
|
||||
</SpecimenGroup>
|
||||
<SpecimenGroup label="Inset — one level of nesting under a row">
|
||||
<div className="component-navigator-section-demo">
|
||||
<NavigatorRow
|
||||
<div className="component-navigation-section-demo">
|
||||
<NavigationItem
|
||||
label="Session interaction model"
|
||||
icon={
|
||||
<IconMessageCircle size={16} stroke={1.7} aria-hidden="true" />
|
||||
@@ -628,6 +629,35 @@ function seedChipFaces() {
|
||||
});
|
||||
}
|
||||
|
||||
function PreviewCardSpecimen() {
|
||||
return (
|
||||
<div className="component-specimen-stack">
|
||||
<SpecimenGroup label="Default">
|
||||
<p className="text-body text-primary">
|
||||
<PreviewCard
|
||||
trigger={
|
||||
<button type="button" className="buzz-preview-card-example">
|
||||
Morgan Martin
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<span className="buzz-preview-card-kind text-body-sm text-tertiary">
|
||||
Person
|
||||
</span>
|
||||
<span className="buzz-preview-card-name text-body text-primary">
|
||||
Morgan Martin
|
||||
</span>
|
||||
<span className="text-body-sm text-secondary">
|
||||
Product designer
|
||||
</span>
|
||||
</PreviewCard>{" "}
|
||||
is reviewing the first Composer composition.
|
||||
</p>
|
||||
</SpecimenGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineChipSpecimen() {
|
||||
const [activated, setActivated] = useState<string | null>(null);
|
||||
seedChipFaces();
|
||||
@@ -657,11 +687,17 @@ function InlineChipSpecimen() {
|
||||
</p>
|
||||
</SpecimenGroup>
|
||||
|
||||
<SpecimenGroup label="Read-only, as a conversation renders it">
|
||||
<SpecimenGroup label="Resolved preview">
|
||||
<p className="text-body text-primary">
|
||||
A chip in a sent message is not a control when the surface has no
|
||||
detail to open:{" "}
|
||||
<InlineChip address={CHIP_PERSON} interactive={false} />
|
||||
Hover or focus <InlineChip address={CHIP_PERSON} /> to reveal its
|
||||
quiet preview. It supplies context without opening another surface.
|
||||
</p>
|
||||
</SpecimenGroup>
|
||||
|
||||
<SpecimenGroup label="Inert rendering">
|
||||
<p className="text-body text-primary">
|
||||
A surface that cannot honestly reveal reference detail renders an
|
||||
inert chip: <InlineChip address={CHIP_PERSON} interactive={false} />
|
||||
</p>
|
||||
</SpecimenGroup>
|
||||
|
||||
@@ -711,11 +747,12 @@ export const COMPONENT_SPECIMENS: Record<string, () => ReactNode> = {
|
||||
button: ButtonSpecimen,
|
||||
"icon-button": IconButtonSpecimen,
|
||||
avatar: AvatarSpecimen,
|
||||
"preview-card": PreviewCardSpecimen,
|
||||
"inline-chip": InlineChipSpecimen,
|
||||
panel: PanelSpecimen,
|
||||
tabs: TabsSpecimen,
|
||||
"panel-header": PanelHeaderSpecimen,
|
||||
"search-field": SearchFieldSpecimen,
|
||||
"navigator-section": NavigatorSectionSpecimen,
|
||||
"navigator-row": NavigatorRowSpecimen,
|
||||
"navigation-section": NavigationSectionSpecimen,
|
||||
"navigation-item": NavigationItemSpecimen,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ReactNode } from "react";
|
||||
* component at a time as the product repeats something.
|
||||
*
|
||||
* Surface convention on these pages: a region is separated by a soft fill, not
|
||||
* by an outline. Reach for `bg-inset` before reaching for a border; use a
|
||||
* by an outline. Reach for `bg-neutral-2` before reaching for a border; use a
|
||||
* hairline only where a genuine boundary is needed, and never above
|
||||
* `border-primary`. See DESIGN.md § Surface and depth.
|
||||
*/
|
||||
@@ -70,7 +70,7 @@ export function Section({
|
||||
|
||||
export function Note({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="max-w-2xl rounded-xl bg-inset px-5 py-4 text-body-sm text-secondary">
|
||||
<p className="max-w-2xl rounded-xl bg-neutral-2 px-5 py-4 text-body-sm text-secondary">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
@@ -78,7 +78,7 @@ export function Note({ children }: { children: ReactNode }) {
|
||||
|
||||
export function Stub({ what, decide }: { what: string; decide: string[] }) {
|
||||
return (
|
||||
<div className="flex max-w-2xl flex-col gap-4 rounded-xl bg-inset px-6 py-5">
|
||||
<div className="flex max-w-2xl flex-col gap-4 rounded-xl bg-neutral-2 px-6 py-5">
|
||||
<p className="text-body text-secondary">{what}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-body text-tertiary">Still to decide</p>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMessagesDestination } from "./resolveMessagesDestination";
|
||||
|
||||
describe("resolveMessagesDestination", () => {
|
||||
const channels = [{ id: "design" }, { id: "session" }];
|
||||
|
||||
it("opens the first ordinary channel when Messages has no selection", () => {
|
||||
expect(
|
||||
resolveMessagesDestination(
|
||||
{ type: "empty" },
|
||||
channels,
|
||||
new Set(["session"]),
|
||||
),
|
||||
).toEqual({ type: "channel", channelId: "design" });
|
||||
});
|
||||
|
||||
it("does not replace an explicit destination", () => {
|
||||
expect(
|
||||
resolveMessagesDestination(
|
||||
{ type: "session", channelId: "session" },
|
||||
channels,
|
||||
new Set(["session"]),
|
||||
),
|
||||
).toEqual({ type: "session", channelId: "session" });
|
||||
});
|
||||
|
||||
it("keeps the empty destination when no ordinary channel is available", () => {
|
||||
expect(
|
||||
resolveMessagesDestination(
|
||||
{ type: "empty" },
|
||||
channels,
|
||||
new Set(channels.map(({ id }) => id)),
|
||||
),
|
||||
).toEqual({ type: "empty" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { MessagesDestination, NavigableChannel } from "./types";
|
||||
|
||||
/**
|
||||
* Resolves Messages' initial destination after its available conversations are
|
||||
* known. Explicit user work always wins; an unselected workspace opens the
|
||||
* first ordinary channel rather than a transitional empty panel.
|
||||
*/
|
||||
export function resolveMessagesDestination(
|
||||
current: MessagesDestination,
|
||||
channels: readonly NavigableChannel[],
|
||||
sessionChannelIds: ReadonlySet<string>,
|
||||
): MessagesDestination {
|
||||
if (current.type !== "empty") return current;
|
||||
|
||||
const firstChannel = channels.find(
|
||||
(channel) => !sessionChannelIds.has(channel.id),
|
||||
);
|
||||
return firstChannel
|
||||
? { type: "channel", channelId: firstChannel.id }
|
||||
: current;
|
||||
}
|
||||
11
desktop-new/src/features/navigation/types.ts
Normal file
11
desktop-new/src/features/navigation/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type MessagesDestination =
|
||||
| { type: "empty" }
|
||||
| { type: "channel"; channelId: string }
|
||||
| { type: "new"; originChannelId?: string }
|
||||
| { type: "session"; channelId: string };
|
||||
|
||||
export type NavigationDestination = "channels" | "agents";
|
||||
|
||||
export type NavigableChannel = {
|
||||
id: string;
|
||||
};
|
||||
231
desktop-new/src/features/navigation/ui/MessagesNavigator.tsx
Normal file
231
desktop-new/src/features/navigation/ui/MessagesNavigator.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
IconHash,
|
||||
IconMessageCircle,
|
||||
IconMessagePlus,
|
||||
IconMessages,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconButton } from "@/shared/ui/IconButton";
|
||||
import { NavigationItem } from "@/shared/ui/NavigationItem";
|
||||
import { NavigationSection } from "@/shared/ui/NavigationSection";
|
||||
import { SearchField } from "@/shared/ui/SearchField";
|
||||
|
||||
type NavigatorConversation = {
|
||||
id: string;
|
||||
name: string;
|
||||
channelType: string;
|
||||
};
|
||||
|
||||
type NavigatorSession = {
|
||||
channelId: string;
|
||||
originChannelId?: string;
|
||||
};
|
||||
|
||||
export function MessagesNavigator({
|
||||
conversations,
|
||||
sessions,
|
||||
selectedId,
|
||||
onOpenRoom,
|
||||
onOpenDirectMessage,
|
||||
onOpenSession,
|
||||
onStartSession,
|
||||
onStartStandaloneSession,
|
||||
query,
|
||||
onQueryChange,
|
||||
}: {
|
||||
conversations: NavigatorConversation[];
|
||||
sessions: NavigatorSession[];
|
||||
selectedId?: string;
|
||||
onOpenRoom: (channelId: string) => void;
|
||||
onOpenDirectMessage: (channelId: string) => void;
|
||||
onOpenSession: (channelId: string) => void;
|
||||
onStartSession: (channelId: string) => void;
|
||||
onStartStandaloneSession: () => void;
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
}) {
|
||||
const sessionsById = new Map(
|
||||
sessions.map((session) => [session.channelId, session]),
|
||||
);
|
||||
const conversationsById = new Map(
|
||||
conversations.map((conversation) => [conversation.id, conversation]),
|
||||
);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const matches = (value: string) =>
|
||||
!normalizedQuery || value.toLowerCase().includes(normalizedQuery);
|
||||
const rooms = conversations.filter(
|
||||
(conversation) =>
|
||||
conversation.channelType !== "dm" && !sessionsById.has(conversation.id),
|
||||
);
|
||||
const directMessages = conversations.filter(
|
||||
(conversation) => conversation.channelType === "dm",
|
||||
);
|
||||
const sessionConversations = sessions
|
||||
.map((session) => ({
|
||||
session,
|
||||
conversation: conversationsById.get(session.channelId),
|
||||
}))
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is {
|
||||
session: NavigatorSession;
|
||||
conversation: NavigatorConversation;
|
||||
} => Boolean(entry.conversation),
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="workspace-navigator" aria-label="Messages">
|
||||
<header className="panel-heading navigator-heading">
|
||||
<SearchField
|
||||
label="Find a Room, person, or Session"
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
onValueChange={onQueryChange}
|
||||
/>
|
||||
</header>
|
||||
<div className="navigator-list">
|
||||
<NavigationSection label="Rooms">
|
||||
{rooms
|
||||
.filter((room) => {
|
||||
const roomSessions = sessions.filter(
|
||||
(session) => session.originChannelId === room.id,
|
||||
);
|
||||
return (
|
||||
matches(room.name) ||
|
||||
roomSessions.some((session) =>
|
||||
matches(conversationsById.get(session.channelId)?.name ?? ""),
|
||||
)
|
||||
);
|
||||
})
|
||||
.map((room) => {
|
||||
const roomSessions = sessions
|
||||
.map((session) => ({
|
||||
session,
|
||||
conversation: conversationsById.get(session.channelId),
|
||||
}))
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is {
|
||||
session: NavigatorSession;
|
||||
conversation: NavigatorConversation;
|
||||
} =>
|
||||
entry.session.originChannelId === room.id &&
|
||||
Boolean(entry.conversation),
|
||||
)
|
||||
.filter(({ conversation }) => matches(conversation.name));
|
||||
return (
|
||||
<div className="channel-cluster" key={room.id}>
|
||||
<div className="channel-main-row">
|
||||
<NavigationItem
|
||||
label={room.name}
|
||||
icon={
|
||||
<IconHash size={15} stroke={1.6} aria-hidden="true" />
|
||||
}
|
||||
selected={selectedId === room.id}
|
||||
onClick={() => onOpenRoom(room.id)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label={`Start Session from ${room.name}`}
|
||||
icon={
|
||||
<IconPlus size={14} stroke={1.7} aria-hidden="true" />
|
||||
}
|
||||
size="compact"
|
||||
onClick={() => onStartSession(room.id)}
|
||||
/>
|
||||
</div>
|
||||
{roomSessions.length ? (
|
||||
<div className="session-children">
|
||||
{roomSessions.map(({ conversation }) => (
|
||||
<div className="session-child" key={conversation.id}>
|
||||
<NavigationItem
|
||||
label={conversation.name}
|
||||
inset
|
||||
selected={selectedId === conversation.id}
|
||||
onClick={() => onOpenSession(conversation.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{rooms.filter((room) => matches(room.name)).length === 0 ? (
|
||||
<p className="navigation-empty text-body-sm text-tertiary">
|
||||
{normalizedQuery ? "No Rooms match." : "No Rooms yet."}
|
||||
</p>
|
||||
) : null}
|
||||
</NavigationSection>
|
||||
|
||||
<NavigationSection label="Direct messages">
|
||||
{directMessages
|
||||
.filter((message) => matches(message.name))
|
||||
.map((message) => (
|
||||
<NavigationItem
|
||||
key={message.id}
|
||||
label={message.name}
|
||||
icon={
|
||||
<IconMessageCircle
|
||||
size={15}
|
||||
stroke={1.6}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
selected={selectedId === message.id}
|
||||
onClick={() => onOpenDirectMessage(message.id)}
|
||||
/>
|
||||
))}
|
||||
{directMessages.filter((message) => matches(message.name)).length ===
|
||||
0 ? (
|
||||
<p className="navigation-empty text-body-sm text-tertiary">
|
||||
{normalizedQuery ? "No people match." : "No direct messages yet."}
|
||||
</p>
|
||||
) : null}
|
||||
</NavigationSection>
|
||||
|
||||
<NavigationSection
|
||||
label="Sessions"
|
||||
action={
|
||||
<IconButton
|
||||
aria-label="Start a standalone Session"
|
||||
icon={
|
||||
<IconMessagePlus size={14} stroke={1.7} aria-hidden="true" />
|
||||
}
|
||||
size="compact"
|
||||
onClick={onStartStandaloneSession}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{sessionConversations
|
||||
.filter(
|
||||
({ conversation, session }) =>
|
||||
!session.originChannelId && matches(conversation.name),
|
||||
)
|
||||
.map(({ conversation }) => (
|
||||
<NavigationItem
|
||||
key={conversation.id}
|
||||
label={conversation.name}
|
||||
icon={
|
||||
<IconMessages size={15} stroke={1.6} aria-hidden="true" />
|
||||
}
|
||||
selected={selectedId === conversation.id}
|
||||
onClick={() => onOpenSession(conversation.id)}
|
||||
/>
|
||||
))}
|
||||
{sessionConversations.filter(
|
||||
({ conversation, session }) =>
|
||||
!session.originChannelId && matches(conversation.name),
|
||||
).length === 0 ? (
|
||||
<p className="navigation-empty text-body-sm text-tertiary">
|
||||
{normalizedQuery
|
||||
? "No Sessions match."
|
||||
: "Start a Session for focused work."}
|
||||
</p>
|
||||
) : null}
|
||||
</NavigationSection>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
55
desktop-new/src/features/navigation/useNavigation.ts
Normal file
55
desktop-new/src/features/navigation/useNavigation.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { resolveMessagesDestination } from "./resolveMessagesDestination";
|
||||
import type {
|
||||
MessagesDestination,
|
||||
NavigableChannel,
|
||||
NavigationDestination,
|
||||
} from "./types";
|
||||
|
||||
export function useNavigation() {
|
||||
const [destination, setDestination] =
|
||||
useState<NavigationDestination>("channels");
|
||||
const [messagesDestination, setMessagesDestination] =
|
||||
useState<MessagesDestination>({ type: "empty" });
|
||||
|
||||
const resolveMessages = useCallback(
|
||||
(
|
||||
channels: readonly NavigableChannel[],
|
||||
sessionChannelIds: ReadonlySet<string>,
|
||||
) => {
|
||||
setMessagesDestination((current) =>
|
||||
resolveMessagesDestination(current, channels, sessionChannelIds),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const openChannel = useCallback((channelId: string) => {
|
||||
setDestination("channels");
|
||||
setMessagesDestination({ type: "channel", channelId });
|
||||
}, []);
|
||||
|
||||
const openSession = useCallback((channelId: string) => {
|
||||
setDestination("channels");
|
||||
setMessagesDestination({ type: "session", channelId });
|
||||
}, []);
|
||||
|
||||
const startSession = useCallback((originChannelId?: string) => {
|
||||
setDestination("channels");
|
||||
setMessagesDestination({ type: "new", originChannelId });
|
||||
}, []);
|
||||
|
||||
const openChannels = useCallback(() => setDestination("channels"), []);
|
||||
const openAgents = useCallback(() => setDestination("agents"), []);
|
||||
|
||||
return {
|
||||
destination,
|
||||
messagesDestination,
|
||||
resolveMessages,
|
||||
openChannel,
|
||||
openSession,
|
||||
startSession,
|
||||
openChannels,
|
||||
openAgents,
|
||||
};
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { projectChannelWindow } from "./channelWindowProjection";
|
||||
|
||||
const event = (kind: number, id: string, content = "content") => ({
|
||||
id,
|
||||
pubkey: "author",
|
||||
content,
|
||||
created_at: 100,
|
||||
kind,
|
||||
tags: [["h", "channel"]],
|
||||
});
|
||||
|
||||
describe("projectChannelWindow", () => {
|
||||
it("keeps protocol overlays and system events out of the transcript", () => {
|
||||
const projection = projectChannelWindow([
|
||||
event(40099, "system", '{"type":"channel_created"}'),
|
||||
event(9, "message", "Hello"),
|
||||
event(39005, "summary", '{"reply_count":2}'),
|
||||
event(39006, "bounds", '{"has_more":false,"next_cursor":null}'),
|
||||
]);
|
||||
|
||||
expect(projection.messages.map(({ id }) => id)).toEqual(["message"]);
|
||||
expect(projection.systemEvents.map(({ id }) => id)).toEqual(["system"]);
|
||||
expect(projection.threadSummaries.map(({ id }) => id)).toEqual(["summary"]);
|
||||
expect(projection.bounds?.id).toBe("bounds");
|
||||
});
|
||||
|
||||
it("rejects an ambiguous window with multiple bounds events", () => {
|
||||
expect(() =>
|
||||
projectChannelWindow([event(39006, "one"), event(39006, "two")]),
|
||||
).toThrow("more than one bounds");
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { Message } from "./types";
|
||||
|
||||
export const CHANNEL_MESSAGE_KINDS = new Set([9, 40002, 40008]);
|
||||
export const CHANNEL_SYSTEM_KIND = 40099;
|
||||
export const THREAD_SUMMARY_KIND = 39005;
|
||||
export const WINDOW_BOUNDS_KIND = 39006;
|
||||
|
||||
type RawChannelEvent = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
};
|
||||
|
||||
export type ChannelWindowProjection = {
|
||||
messages: Message[];
|
||||
systemEvents: RawChannelEvent[];
|
||||
threadSummaries: RawChannelEvent[];
|
||||
bounds: RawChannelEvent | null;
|
||||
};
|
||||
|
||||
export function projectChannelWindow(
|
||||
events: RawChannelEvent[],
|
||||
): ChannelWindowProjection {
|
||||
const messages: Message[] = [];
|
||||
const systemEvents: RawChannelEvent[] = [];
|
||||
const threadSummaries: RawChannelEvent[] = [];
|
||||
let bounds: RawChannelEvent | null = null;
|
||||
|
||||
for (const event of events) {
|
||||
if (CHANNEL_MESSAGE_KINDS.has(event.kind)) {
|
||||
messages.push({
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
content: event.content,
|
||||
createdAt: event.created_at,
|
||||
kind: event.kind,
|
||||
tags: event.tags,
|
||||
});
|
||||
} else if (event.kind === CHANNEL_SYSTEM_KIND) {
|
||||
systemEvents.push(event);
|
||||
} else if (event.kind === THREAD_SUMMARY_KIND) {
|
||||
threadSummaries.push(event);
|
||||
} else if (event.kind === WINDOW_BOUNDS_KIND) {
|
||||
if (bounds)
|
||||
throw new Error("Channel window returned more than one bounds event.");
|
||||
bounds = event;
|
||||
}
|
||||
}
|
||||
|
||||
messages.sort((left, right) => left.createdAt - right.createdAt);
|
||||
return { messages, systemEvents, threadSummaries, bounds };
|
||||
}
|
||||
@@ -39,41 +39,3 @@ export type SessionRecord = {
|
||||
updatedAt: number;
|
||||
incompleteDraft?: string;
|
||||
};
|
||||
|
||||
export type ActivityStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "needs_you"
|
||||
| "unavailable";
|
||||
|
||||
export type ActivityItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
status: ActivityStatus;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type AgentTurn = {
|
||||
key: string;
|
||||
agentPubkey: string;
|
||||
agentName: string;
|
||||
sessionId: string;
|
||||
turnId: string;
|
||||
status: ActivityStatus;
|
||||
items: ActivityItem[];
|
||||
};
|
||||
|
||||
export type ObserverEvent = {
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
kind: string;
|
||||
channelId?: string;
|
||||
sessionId?: string;
|
||||
turnId?: string;
|
||||
agentPubkey?: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
IconLoader2,
|
||||
IconPlayerPause,
|
||||
} from "@tabler/icons-react";
|
||||
import { partitionActivity } from "../activityProjection";
|
||||
import type { ActivityItem, AgentTurn } from "../types";
|
||||
import { partitionActivity } from "@/features/agent-activity/activityProjection";
|
||||
import type { ActivityItem, AgentTurn } from "@/features/agent-activity/types";
|
||||
|
||||
function ActivityIcon({ item }: { item: ActivityItem }) {
|
||||
const common = { size: 15, stroke: 1.7, "aria-hidden": true } as const;
|
||||
|
||||
@@ -1,163 +1,59 @@
|
||||
import { IconHash, IconMoon, IconPlus, IconSun } from "@tabler/icons-react";
|
||||
import { IconHash, IconMoon, IconSun } from "@tabler/icons-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAgentActivity } from "@/features/agent-activity/useAgentActivity";
|
||||
import { useComposerDrafts } from "@/features/composer/useComposerDrafts";
|
||||
import { useAgentsWorkspace } from "@/features/agents/ui/AgentsWorkspace";
|
||||
import { runtime } from "@/shared/runtime/client";
|
||||
import { MessagesNavigator } from "@/features/navigation/ui/MessagesNavigator";
|
||||
import { useNavigation } from "@/features/navigation/useNavigation";
|
||||
import { communityScope, useCommunity } from "@/shared/community/useCommunity";
|
||||
import { useIdentity } from "@/shared/identity/useIdentity";
|
||||
import { isMockRuntime, runtime } from "@/shared/runtime/client";
|
||||
import { useColorScheme } from "@/shared/theme/useColorScheme";
|
||||
import { DockWorkspace } from "@/shared/ui/DockWorkspace";
|
||||
import { Panel } from "@/shared/ui/Panel";
|
||||
import {
|
||||
listSessions,
|
||||
rememberSession,
|
||||
updateSession,
|
||||
} from "../sessionRegistry";
|
||||
import type { Channel, Identity, Message, SessionRecord } from "../types";
|
||||
import type { Channel, Message, SessionRecord } from "../types";
|
||||
import { NewSessionView } from "./NewSessionView";
|
||||
import { SessionView, useAgentTurns } from "./SessionView";
|
||||
|
||||
type View =
|
||||
| { type: "empty" }
|
||||
| { type: "channel"; channelId: string }
|
||||
| { type: "new"; originChannelId: string }
|
||||
| { type: "session"; channelId: string };
|
||||
|
||||
type Destination = "channels" | "agents";
|
||||
|
||||
function ChannelNavigator({
|
||||
channels,
|
||||
sessions,
|
||||
selectedId,
|
||||
onOpen,
|
||||
onOpenSession,
|
||||
onNewSession,
|
||||
query,
|
||||
onQueryChange,
|
||||
}: {
|
||||
channels: Channel[];
|
||||
sessions: SessionRecord[];
|
||||
selectedId?: string;
|
||||
onOpen: (channelId: string) => void;
|
||||
onOpenSession: (channelId: string) => void;
|
||||
onNewSession: (channelId: string) => void;
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
}) {
|
||||
const sessionIds = new Set(sessions.map((session) => session.channelId));
|
||||
return (
|
||||
<aside className="workspace-navigator" aria-label="Channels">
|
||||
<header className="panel-heading navigator-heading">
|
||||
<label className="navigator-search">
|
||||
<span className="sr-only">Find a channel or Session</span>
|
||||
<input
|
||||
placeholder="Find a channel or Session"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</header>
|
||||
<div className="navigator-list">
|
||||
<p className="navigator-section-label text-body-sm text-tertiary">
|
||||
Channels and Sessions
|
||||
</p>
|
||||
{channels
|
||||
.filter((channel) => !sessionIds.has(channel.id))
|
||||
.filter((channel) => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
return (
|
||||
channel.name.toLowerCase().includes(normalized) ||
|
||||
sessions
|
||||
.filter((session) => session.originChannelId === channel.id)
|
||||
.some((session) =>
|
||||
channels
|
||||
.find((candidate) => candidate.id === session.channelId)
|
||||
?.name.toLowerCase()
|
||||
.includes(normalized),
|
||||
)
|
||||
);
|
||||
})
|
||||
.map((channel) => {
|
||||
const channelSessions = sessions.filter(
|
||||
(session) => session.originChannelId === channel.id,
|
||||
);
|
||||
return (
|
||||
<section className="channel-cluster" key={channel.id}>
|
||||
<div className="channel-main-row">
|
||||
<button
|
||||
type="button"
|
||||
className="navigator-row"
|
||||
data-selected={selectedId === channel.id || undefined}
|
||||
onClick={() => onOpen(channel.id)}
|
||||
>
|
||||
<IconHash size={15} stroke={1.6} aria-hidden="true" />
|
||||
<span>{channel.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="navigator-row-action"
|
||||
aria-label={`New Session in ${channel.name}`}
|
||||
onClick={() => onNewSession(channel.id)}
|
||||
>
|
||||
<IconPlus size={14} stroke={1.7} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{channelSessions.length ? (
|
||||
<div className="session-children">
|
||||
{channelSessions.map((session) => {
|
||||
const sessionChannel = channels.find(
|
||||
(candidate) => candidate.id === session.channelId,
|
||||
);
|
||||
if (!sessionChannel) return null;
|
||||
return (
|
||||
<button
|
||||
key={session.channelId}
|
||||
type="button"
|
||||
className="session-child-row"
|
||||
data-selected={
|
||||
selectedId === session.channelId || undefined
|
||||
}
|
||||
onClick={() => onOpenSession(session.channelId)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="session-thread-line"
|
||||
/>
|
||||
<span>{sessionChannel.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
import { SessionView } from "./SessionView";
|
||||
|
||||
export function AppShell() {
|
||||
const [identity, setIdentity] = useState<Identity | null>(null);
|
||||
const [relay, setRelay] = useState("");
|
||||
const { identity, load: loadIdentity } = useIdentity();
|
||||
const { relayUrl: relay, load: loadCommunity } = useCommunity();
|
||||
const { scheme, toggle: toggleColorScheme } = useColorScheme();
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [sessions, setSessions] = useState<SessionRecord[]>([]);
|
||||
const [view, setView] = useState<View>({ type: "empty" });
|
||||
const [destination, setDestination] = useState<Destination>("channels");
|
||||
const [pending, setPending] = useState<Message>();
|
||||
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
|
||||
const [dark, setDark] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
const [channelQuery, setChannelQuery] = useState("");
|
||||
const turns = useAgentTurns();
|
||||
const {
|
||||
destination,
|
||||
messagesDestination: view,
|
||||
resolveMessages,
|
||||
openAgents,
|
||||
openChannel,
|
||||
openChannels,
|
||||
openSession: navigateToSession,
|
||||
startSession,
|
||||
} = useNavigation();
|
||||
const agentActivity = useAgentActivity();
|
||||
const composerDrafts = useComposerDrafts();
|
||||
const agentsWorkspace = useAgentsWorkspace();
|
||||
const scope = identity ? `${relay}:${identity.pubkey}` : "";
|
||||
const scope = identity ? communityScope(relay, identity.pubkey) : "";
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [nextIdentity, nextRelay, nextChannels, pendingBootstraps] =
|
||||
const [nextIdentity, nextRelay, loadedChannels, pendingBootstraps] =
|
||||
await Promise.all([
|
||||
runtime.identity(),
|
||||
runtime.relayUrl(),
|
||||
loadIdentity(),
|
||||
loadCommunity(),
|
||||
runtime.channels(),
|
||||
runtime.pendingSessionBootstraps(),
|
||||
]);
|
||||
let effectiveChannels = nextChannels;
|
||||
let effectiveChannels = loadedChannels;
|
||||
const failedRecoveries = (
|
||||
await Promise.all(
|
||||
pendingBootstraps.map(async (operation) => {
|
||||
@@ -173,7 +69,7 @@ export function AppShell() {
|
||||
if (pendingBootstraps.length > failedRecoveries.length) {
|
||||
effectiveChannels = await runtime.channels();
|
||||
}
|
||||
const nextScope = `${nextRelay}:${nextIdentity.pubkey}`;
|
||||
const nextScope = communityScope(nextRelay, nextIdentity.pubkey);
|
||||
const discoveredLinks = (
|
||||
await Promise.all(
|
||||
effectiveChannels.map((parent) =>
|
||||
@@ -211,9 +107,7 @@ export function AppShell() {
|
||||
lastMessageAt: null,
|
||||
}),
|
||||
);
|
||||
setIdentity(nextIdentity);
|
||||
setRelay(nextRelay);
|
||||
setChannels([
|
||||
const nextChannels = [
|
||||
...incompleteChannels,
|
||||
...discoveredChannels,
|
||||
...effectiveChannels.filter(
|
||||
@@ -222,7 +116,15 @@ export function AppShell() {
|
||||
(incomplete) => incomplete.id === channel.id,
|
||||
) && !discoveredChannels.some((session) => session.id === channel.id),
|
||||
),
|
||||
]);
|
||||
];
|
||||
setChannels(nextChannels);
|
||||
resolveMessages(
|
||||
nextChannels,
|
||||
new Set([
|
||||
...incompleteChannels.map((channel) => channel.id),
|
||||
...discoveredChannels.map((channel) => channel.id),
|
||||
]),
|
||||
);
|
||||
for (const link of discoveredLinks) {
|
||||
rememberSession(nextScope, {
|
||||
channelId: link.session_channel_id,
|
||||
@@ -245,9 +147,30 @@ export function AppShell() {
|
||||
});
|
||||
}
|
||||
}
|
||||
setSessions(listSessions(nextScope));
|
||||
const rememberedSessions = listSessions(nextScope);
|
||||
// Rich browser fixtures give the navigation a real standalone Session to
|
||||
// arrange and open without claiming the current native parent-required
|
||||
// bootstrap contract can create one yet.
|
||||
const fixtureSessions = isMockRuntime
|
||||
? [
|
||||
{
|
||||
channelId: "session-agent-setup",
|
||||
createdAt: Date.now() - 10_800_000,
|
||||
updatedAt: Date.now() - 10_800_000,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
setSessions([
|
||||
...fixtureSessions,
|
||||
...rememberedSessions.filter(
|
||||
(remembered) =>
|
||||
!fixtureSessions.some(
|
||||
(fixture) => fixture.channelId === remembered.channelId,
|
||||
),
|
||||
),
|
||||
]);
|
||||
setStartupError(null);
|
||||
}, []);
|
||||
}, [loadCommunity, loadIdentity, resolveMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh().catch((caught) => {
|
||||
@@ -257,10 +180,6 @@ export function AppShell() {
|
||||
});
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
}, [dark]);
|
||||
|
||||
const channelsById = useMemo(
|
||||
() => new Map(channels.map((channel) => [channel.id, channel])),
|
||||
[channels],
|
||||
@@ -269,6 +188,12 @@ export function AppShell() {
|
||||
async function createSession(content: string) {
|
||||
if (view.type !== "new") return;
|
||||
const originChannelId = view.originChannelId;
|
||||
if (!originChannelId) {
|
||||
const message =
|
||||
"Standalone Sessions need the native creation contract before they can be started here.";
|
||||
setBootstrapError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
const operationId =
|
||||
pending?.pending === "failed"
|
||||
? pending.id
|
||||
@@ -299,7 +224,7 @@ export function AppShell() {
|
||||
setChannels((current) => [created, ...current]);
|
||||
setSessions(listSessions(scope));
|
||||
setPending(undefined);
|
||||
setView({ type: "session", channelId: created.id });
|
||||
navigateToSession(created.id);
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : String(caught);
|
||||
setPending({ ...optimistic, pending: "failed", error: message });
|
||||
@@ -311,7 +236,7 @@ export function AppShell() {
|
||||
function openSession(channelId: string) {
|
||||
updateSession(scope, channelId, { updatedAt: Date.now() });
|
||||
setSessions(listSessions(scope));
|
||||
setView({ type: "session", channelId });
|
||||
navigateToSession(channelId);
|
||||
}
|
||||
|
||||
if (!identity) {
|
||||
@@ -331,6 +256,16 @@ export function AppShell() {
|
||||
);
|
||||
}
|
||||
|
||||
const draftDestinationId =
|
||||
view.type === "channel" || view.type === "session"
|
||||
? `conversation:${view.channelId}`
|
||||
: view.type === "new"
|
||||
? `new-session:${view.originChannelId ?? "standalone"}`
|
||||
: undefined;
|
||||
const draft = draftDestinationId
|
||||
? composerDrafts.readDraft(draftDestinationId)
|
||||
: "";
|
||||
|
||||
const activeId =
|
||||
view.type === "channel" || view.type === "session"
|
||||
? view.channelId
|
||||
@@ -353,9 +288,18 @@ export function AppShell() {
|
||||
pending={pending}
|
||||
error={bootstrapError}
|
||||
onBack={() =>
|
||||
setView({ type: "channel", channelId: view.originChannelId })
|
||||
view.originChannelId
|
||||
? openChannel(view.originChannelId)
|
||||
: openChannels()
|
||||
}
|
||||
onCreate={createSession}
|
||||
draft={draft}
|
||||
onDraftChange={(value) =>
|
||||
composerDrafts.writeDraft(
|
||||
`new-session:${view.originChannelId ?? "standalone"}`,
|
||||
value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : activeChannel &&
|
||||
(view.type === "channel" || view.type === "session") ? (
|
||||
@@ -363,13 +307,17 @@ export function AppShell() {
|
||||
channel={activeChannel}
|
||||
identity={identity}
|
||||
origin={origin}
|
||||
turns={turns}
|
||||
turns={agentActivity.forChannel(activeChannel.id)}
|
||||
mode={view.type}
|
||||
onStartSession={
|
||||
view.type === "channel"
|
||||
? () => setView({ type: "new", originChannelId: activeChannel.id })
|
||||
? () => startSession(activeChannel.id)
|
||||
: undefined
|
||||
}
|
||||
draft={draft}
|
||||
onDraftChange={(value) =>
|
||||
composerDrafts.writeDraft(`conversation:${activeChannel.id}`, value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<main className="empty-workspace-panel">
|
||||
@@ -388,14 +336,14 @@ export function AppShell() {
|
||||
<button
|
||||
type="button"
|
||||
data-selected={destination === "channels" || undefined}
|
||||
onClick={() => setDestination("channels")}
|
||||
onClick={openChannels}
|
||||
>
|
||||
Channels
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-selected={destination === "agents" || undefined}
|
||||
onClick={() => setDestination("agents")}
|
||||
onClick={openAgents}
|
||||
>
|
||||
Agents
|
||||
</button>
|
||||
@@ -407,10 +355,10 @@ export function AppShell() {
|
||||
<button
|
||||
type="button"
|
||||
className="topbar-icon-button"
|
||||
onClick={() => setDark((value) => !value)}
|
||||
aria-label={dark ? "Use light mode" : "Use dark mode"}
|
||||
onClick={toggleColorScheme}
|
||||
aria-label={scheme === "dark" ? "Use light mode" : "Use dark mode"}
|
||||
>
|
||||
{dark ? (
|
||||
{scheme === "dark" ? (
|
||||
<IconSun size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<IconMoon size={16} aria-hidden="true" />
|
||||
@@ -423,25 +371,28 @@ export function AppShell() {
|
||||
panels={{
|
||||
navigator:
|
||||
destination === "channels" ? (
|
||||
<ChannelNavigator
|
||||
channels={channels}
|
||||
sessions={sessions}
|
||||
selectedId={activeId}
|
||||
onOpen={(channelId) => {
|
||||
setDestination("channels");
|
||||
setView({ type: "channel", channelId });
|
||||
}}
|
||||
onOpenSession={openSession}
|
||||
onNewSession={(originChannelId) =>
|
||||
setView({ type: "new", originChannelId })
|
||||
}
|
||||
query={channelQuery}
|
||||
onQueryChange={setChannelQuery}
|
||||
/>
|
||||
<Panel as="aside" aria-label="Channels">
|
||||
<MessagesNavigator
|
||||
conversations={channels}
|
||||
sessions={sessions}
|
||||
selectedId={activeId}
|
||||
onOpenRoom={openChannel}
|
||||
onOpenDirectMessage={openChannel}
|
||||
onOpenSession={openSession}
|
||||
onStartSession={startSession}
|
||||
onStartStandaloneSession={() => startSession()}
|
||||
query={channelQuery}
|
||||
onQueryChange={setChannelQuery}
|
||||
/>
|
||||
</Panel>
|
||||
) : (
|
||||
agentsWorkspace.navigator
|
||||
<Panel as="aside" aria-label="Agents">
|
||||
{agentsWorkspace.navigator}
|
||||
</Panel>
|
||||
),
|
||||
conversation,
|
||||
conversation: (
|
||||
<Panel aria-label="Workspace content">{conversation}</Panel>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IconArrowLeft, IconHash, IconLock } from "@tabler/icons-react";
|
||||
import { MessageComposer } from "@/features/composer/ui/MessageComposer";
|
||||
import type { Channel, Message } from "../types";
|
||||
import { SessionComposer } from "./SessionComposer";
|
||||
|
||||
export function NewSessionView({
|
||||
origin,
|
||||
@@ -8,12 +8,16 @@ export function NewSessionView({
|
||||
error,
|
||||
onBack,
|
||||
onCreate,
|
||||
draft,
|
||||
onDraftChange,
|
||||
}: {
|
||||
origin?: Channel;
|
||||
pending?: Message;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onCreate: (content: string) => Promise<void>;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<main className="session-view new-session">
|
||||
@@ -75,9 +79,10 @@ export function NewSessionView({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="composer-dock">
|
||||
<SessionComposer
|
||||
initialValue={pending?.pending === "failed" ? pending.content : ""}
|
||||
<MessageComposer
|
||||
draft={pending?.pending === "failed" ? pending.content : draft}
|
||||
disabled={Boolean(pending && pending.pending !== "failed")}
|
||||
onDraftChange={onDraftChange}
|
||||
onSend={onCreate}
|
||||
placeholder="Start a Session"
|
||||
/>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { IconArrowUp } from "@tabler/icons-react";
|
||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
|
||||
export function SessionComposer({
|
||||
disabled,
|
||||
initialValue = "",
|
||||
onSend,
|
||||
placeholder = "Message the session",
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
initialValue?: string;
|
||||
onSend: (content: string) => Promise<void>;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [sending, setSending] = useState(false);
|
||||
const textarea = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
textarea.current?.focus();
|
||||
}, []);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const content = value.trim();
|
||||
if (!content || sending || disabled) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await onSend(content);
|
||||
setValue("");
|
||||
} finally {
|
||||
setSending(false);
|
||||
textarea.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="session-composer" onSubmit={submit}>
|
||||
<textarea
|
||||
ref={textarea}
|
||||
value={value}
|
||||
rows={1}
|
||||
aria-label={placeholder}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="send-button"
|
||||
aria-label={sending ? "Sending message" : "Send message"}
|
||||
disabled={!value.trim() || sending || disabled}
|
||||
>
|
||||
<IconArrowUp size={18} stroke={1.8} aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,11 @@
|
||||
import { IconAlertCircle, IconHash, IconRefresh } from "@tabler/icons-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { runtime } from "@/shared/runtime/client";
|
||||
import { reduceActivity } from "../activityProjection";
|
||||
import type {
|
||||
AgentTurn,
|
||||
Channel,
|
||||
Identity,
|
||||
Message,
|
||||
ObserverEvent,
|
||||
Participant,
|
||||
} from "../types";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useConversation } from "@/features/conversation/useConversation";
|
||||
import { ConversationComposerDock } from "@/features/conversation/ui/ConversationComposerDock";
|
||||
import type { AgentTurn } from "@/features/agent-activity/types";
|
||||
import type { Channel, Identity, Message, Participant } from "../types";
|
||||
import { AgentActivity } from "./AgentActivity";
|
||||
import { ParticipantDialog } from "./ParticipantDialog";
|
||||
import { SessionComposer } from "./SessionComposer";
|
||||
|
||||
function displayName(
|
||||
pubkey: string,
|
||||
@@ -30,10 +23,12 @@ function MessageRow({
|
||||
message,
|
||||
participants,
|
||||
identity,
|
||||
onRetry,
|
||||
}: {
|
||||
message: Message;
|
||||
participants: Participant[];
|
||||
identity: Identity;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const name = displayName(message.pubkey, participants, identity);
|
||||
const isSelf = message.pubkey === identity.pubkey;
|
||||
@@ -65,15 +60,22 @@ function MessageRow({
|
||||
{message.content}
|
||||
</p>
|
||||
{message.pending ? (
|
||||
<p className="mt-1 text-body-sm text-tertiary" role="status">
|
||||
{message.pending === "creating"
|
||||
? "Creating Session"
|
||||
: message.pending === "waiting"
|
||||
? "Waiting for connection"
|
||||
: message.pending === "failed"
|
||||
? (message.error ?? "Not sent")
|
||||
: "Sending"}
|
||||
</p>
|
||||
<div className="mt-1 text-body-sm text-tertiary" role="status">
|
||||
<span>
|
||||
{message.pending === "creating"
|
||||
? "Creating Session"
|
||||
: message.pending === "waiting"
|
||||
? "Waiting for connection"
|
||||
: message.pending === "failed"
|
||||
? (message.error ?? "Not sent")
|
||||
: "Sending"}
|
||||
</span>
|
||||
{message.pending === "failed" && onRetry ? (
|
||||
<button type="button" className="quiet-button" onClick={onRetry}>
|
||||
Retry send
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
@@ -87,6 +89,8 @@ export function SessionView({
|
||||
turns,
|
||||
mode,
|
||||
onStartSession,
|
||||
draft,
|
||||
onDraftChange,
|
||||
}: {
|
||||
channel: Channel;
|
||||
identity: Identity;
|
||||
@@ -94,105 +98,15 @@ export function SessionView({
|
||||
turns: AgentTurn[];
|
||||
mode: "channel" | "session";
|
||||
onStartSession?: () => void;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { messages, participants, loading, error, refresh, retrySend, send } =
|
||||
useConversation(channel, identity);
|
||||
|
||||
const refreshParticipants = useCallback(async () => {
|
||||
setParticipants(await runtime.participants(channel.id));
|
||||
}, [channel.id]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextMessages] = await Promise.all([
|
||||
runtime.messages(channel.id),
|
||||
refreshParticipants(),
|
||||
]);
|
||||
setMessages((current) => {
|
||||
const merged = new Map(current.map((item) => [item.id, item]));
|
||||
for (const item of nextMessages) merged.set(item.id, item);
|
||||
return [...merged.values()].sort(
|
||||
(left, right) => left.createdAt - right.createdAt,
|
||||
);
|
||||
});
|
||||
setError(null);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [channel.id, refreshParticipants]);
|
||||
|
||||
useEffect(() => {
|
||||
let stop: undefined | (() => void);
|
||||
let disposed = false;
|
||||
void refresh();
|
||||
void runtime
|
||||
.subscribeMessages(channel.id, (incoming) => {
|
||||
setMessages((current) => {
|
||||
if (current.some((item) => item.id === incoming.id)) return current;
|
||||
return [...current, incoming].sort(
|
||||
(left, right) => left.createdAt - right.createdAt,
|
||||
);
|
||||
});
|
||||
})
|
||||
.then((cleanup) => {
|
||||
if (disposed) cleanup();
|
||||
else stop = cleanup;
|
||||
})
|
||||
.catch((caught) => {
|
||||
if (!disposed) {
|
||||
setError(
|
||||
caught instanceof Error
|
||||
? `Live updates unavailable: ${caught.message}`
|
||||
: "Live updates unavailable.",
|
||||
);
|
||||
}
|
||||
});
|
||||
const timer = window.setInterval(() => void refresh(), 30_000);
|
||||
return () => {
|
||||
disposed = true;
|
||||
stop?.();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [channel.id, refresh]);
|
||||
|
||||
async function send(content: string) {
|
||||
const pendingId = `pending-${crypto.randomUUID()}`;
|
||||
const pending: Message = {
|
||||
id: pendingId,
|
||||
pubkey: identity.pubkey,
|
||||
content,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
kind: 9,
|
||||
tags: [["h", channel.id]],
|
||||
pending: navigator.onLine ? "sending" : "waiting",
|
||||
};
|
||||
setMessages((current) => [...current, pending]);
|
||||
try {
|
||||
const accepted = await runtime.sendMessage(channel.id, content);
|
||||
setMessages((current) =>
|
||||
current.map((item) => (item.id === pendingId ? accepted : item)),
|
||||
);
|
||||
} catch (caught) {
|
||||
setMessages((current) =>
|
||||
current.map((item) =>
|
||||
item.id === pendingId
|
||||
? {
|
||||
...item,
|
||||
pending: "failed",
|
||||
error:
|
||||
caught instanceof Error ? caught.message : String(caught),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
throw caught;
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const timeline = useMemo(() => {
|
||||
const channelTurns = turns.filter((turn) =>
|
||||
@@ -283,6 +197,11 @@ export function SessionView({
|
||||
message={item.message}
|
||||
participants={participants}
|
||||
identity={identity}
|
||||
onRetry={
|
||||
item.message.pending === "failed"
|
||||
? () => void retrySend(item.message.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AgentActivity key={item.key} turn={item.turn} />
|
||||
@@ -290,27 +209,12 @@ export function SessionView({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="composer-dock">
|
||||
<SessionComposer onSend={send} />
|
||||
</div>
|
||||
<ConversationComposerDock
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
onSend={send}
|
||||
turns={turns}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAgentTurns() {
|
||||
const [projection, setProjection] = useState<Map<string, AgentTurn>>(
|
||||
new Map(),
|
||||
);
|
||||
useEffect(() => {
|
||||
let stop: undefined | (() => void);
|
||||
void runtime
|
||||
.observe((event: ObserverEvent) => {
|
||||
setProjection((current) => reduceActivity(current, event));
|
||||
})
|
||||
.then((cleanup) => {
|
||||
stop = cleanup;
|
||||
});
|
||||
return () => stop?.();
|
||||
}, []);
|
||||
return [...projection.values()];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import "@fontsource-variable/inter/wght.css";
|
||||
import "@fontsource/jetbrains-mono/400.css";
|
||||
import "./shared/styles/globals.css";
|
||||
import { routeTree } from "./app/routeTree.gen";
|
||||
import { useKeyboardFocusVisibility } from "./app/useKeyboardFocusVisibility";
|
||||
|
||||
const router = createRouter({ routeTree });
|
||||
|
||||
@@ -15,11 +16,16 @@ declare module "@tanstack/react-router" {
|
||||
}
|
||||
}
|
||||
|
||||
function AppRoot() {
|
||||
useKeyboardFocusVisibility();
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("Missing #root element");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<AppRoot />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
34
desktop-new/src/shared/community/useCommunity.ts
Normal file
34
desktop-new/src/shared/community/useCommunity.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { runtime } from "@/shared/runtime/client";
|
||||
|
||||
/**
|
||||
* Which community this client is connected to.
|
||||
*
|
||||
* A fact, like identity: one relay URL, no policy. Community *switching* — the
|
||||
* teardown ordering and reset sequence — is a capability, and does not live here.
|
||||
*/
|
||||
export async function readRelayUrl(): Promise<string> {
|
||||
return runtime.relayUrl();
|
||||
}
|
||||
|
||||
/** The same fact for a component that wants it in render. */
|
||||
export function useCommunity() {
|
||||
const [relayUrl, setRelayUrl] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const next = await readRelayUrl();
|
||||
setRelayUrl(next);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
return { relayUrl, load };
|
||||
}
|
||||
|
||||
/**
|
||||
* The storage key for anything scoped to one person in one community. Derived in
|
||||
* one place so a second caller cannot invent a different spelling of the same
|
||||
* scope.
|
||||
*/
|
||||
export function communityScope(relayUrl: string, pubkey: string) {
|
||||
return `${relayUrl}:${pubkey}`;
|
||||
}
|
||||
31
desktop-new/src/shared/identity/useIdentity.ts
Normal file
31
desktop-new/src/shared/identity/useIdentity.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { runtime } from "@/shared/runtime/client";
|
||||
import type { Identity } from "@/features/sessions/types";
|
||||
|
||||
/**
|
||||
* Who the person using this client is.
|
||||
*
|
||||
* A fact, not a capability: it answers a question and owns no policy. Nothing in
|
||||
* the app can legitimately disagree about it, so it has one reader-facing shape
|
||||
* and no fallback, retry, or ordering rules of its own.
|
||||
*
|
||||
* Deliberately not cached at module level. A cache here would be
|
||||
* community-scoped state owing a teardown, which is a real obligation this fact
|
||||
* does not need — the backend call is cheap and the value is stable.
|
||||
*/
|
||||
export async function readIdentity(): Promise<Identity> {
|
||||
return runtime.identity();
|
||||
}
|
||||
|
||||
/** The same fact for a component that wants it in render. */
|
||||
export function useIdentity() {
|
||||
const [identity, setIdentity] = useState<Identity | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const next = await readIdentity();
|
||||
setIdentity(next);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
return { identity, load };
|
||||
}
|
||||
20
desktop-new/src/shared/runtime/channelWindow.ts
Normal file
20
desktop-new/src/shared/runtime/channelWindow.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** The minimal wire contract for one relay-computed channel-window request. */
|
||||
export type ChannelWindowCursor = {
|
||||
createdAt: number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type ChannelWindowRequest = {
|
||||
channelId: string;
|
||||
cursor?: ChannelWindowCursor | null;
|
||||
};
|
||||
|
||||
/** Raw signed event returned by the relay/Tauri channel-window adapter. */
|
||||
export type RawChannelEvent = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Channel, invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { projectChannelWindow } from "@/features/sessions/channelWindowProjection";
|
||||
import type { ChannelWindowRequest, RawChannelEvent } from "./channelWindow";
|
||||
import type {
|
||||
Channel as BuzzChannel,
|
||||
Identity,
|
||||
Message,
|
||||
ObserverEvent,
|
||||
Participant,
|
||||
} from "@/features/sessions/types";
|
||||
import type { ObserverEvent } from "./observerEvent";
|
||||
|
||||
type RawIdentity = { pubkey: string; display_name: string };
|
||||
type RawChannel = {
|
||||
@@ -25,14 +25,7 @@ type RawMember = {
|
||||
is_agent: boolean;
|
||||
display_name?: string | null;
|
||||
};
|
||||
type RawEvent = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
created_at: number;
|
||||
kind: number;
|
||||
tags: string[][];
|
||||
};
|
||||
type RawEvent = RawChannelEvent;
|
||||
|
||||
const LIVE_MESSAGE_KINDS = [9, 40002, 40008];
|
||||
|
||||
@@ -41,6 +34,7 @@ type MockState = {
|
||||
channels: BuzzChannel[];
|
||||
messages: Record<string, Message[]>;
|
||||
participants: Record<string, Participant[]>;
|
||||
sessionLinks: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const mockState: MockState = {
|
||||
@@ -64,6 +58,51 @@ const mockState: MockState = {
|
||||
memberCount: 7,
|
||||
lastMessageAt: new Date(Date.now() - 86_400_000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "launch-planning",
|
||||
name: "launch-planning",
|
||||
channelType: "stream",
|
||||
visibility: "private",
|
||||
description: "Planning the next Buzz release",
|
||||
memberCount: 5,
|
||||
lastMessageAt: new Date(Date.now() - 172_800_000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "cynthia",
|
||||
name: "Cynthia Chen",
|
||||
channelType: "dm",
|
||||
visibility: "private",
|
||||
description: "Direct message",
|
||||
memberCount: 2,
|
||||
lastMessageAt: new Date(Date.now() - 900_000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "design-sync",
|
||||
name: "Cynthia Chen, Alex Kim",
|
||||
channelType: "dm",
|
||||
visibility: "private",
|
||||
description: "Direct message",
|
||||
memberCount: 3,
|
||||
lastMessageAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "session-navigation",
|
||||
name: "Refine the conversation hierarchy",
|
||||
channelType: "stream",
|
||||
visibility: "private",
|
||||
description: "Focused work",
|
||||
memberCount: 2,
|
||||
lastMessageAt: new Date(Date.now() - 1_800_000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "session-agent-setup",
|
||||
name: "Review agent setup",
|
||||
channelType: "stream",
|
||||
visibility: "private",
|
||||
description: "Focused work",
|
||||
memberCount: 1,
|
||||
lastMessageAt: new Date(Date.now() - 10_800_000).toISOString(),
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
design: [
|
||||
@@ -77,6 +116,9 @@ const mockState: MockState = {
|
||||
},
|
||||
],
|
||||
},
|
||||
sessionLinks: {
|
||||
design: ["session-navigation"],
|
||||
},
|
||||
participants: {
|
||||
design: [
|
||||
{
|
||||
@@ -103,6 +145,9 @@ const mockState: MockState = {
|
||||
|
||||
const useMock = !isTauri() || new URLSearchParams(location.search).has("mock");
|
||||
|
||||
/** Browser fixtures use the same runtime adapter as the native client. */
|
||||
export const isMockRuntime = useMock;
|
||||
|
||||
function relayFrames(delivery: unknown): unknown[] {
|
||||
const envelopes = Array.isArray(delivery) ? delivery : [delivery];
|
||||
return envelopes.flatMap((envelope) => {
|
||||
@@ -222,7 +267,31 @@ export const runtime = {
|
||||
},
|
||||
|
||||
async channelSessions(parentChannelId: string) {
|
||||
if (useMock) return [];
|
||||
if (useMock) {
|
||||
return (mockState.sessionLinks[parentChannelId] ?? []).map(
|
||||
(sessionChannelId) => ({
|
||||
parent_channel_id: parentChannelId,
|
||||
session_channel_id: sessionChannelId,
|
||||
creator_pubkey: mockState.identity.pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000) - 3_600,
|
||||
channel: (() => {
|
||||
const channel = mockState.channels.find(
|
||||
(candidate) => candidate.id === sessionChannelId,
|
||||
);
|
||||
return channel
|
||||
? {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
channel_type: channel.channelType,
|
||||
visibility: channel.visibility,
|
||||
description: channel.description,
|
||||
member_count: channel.memberCount,
|
||||
}
|
||||
: null;
|
||||
})(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return invoke<
|
||||
{
|
||||
parent_channel_id: string;
|
||||
@@ -266,14 +335,41 @@ export const runtime = {
|
||||
return (raw.channels ?? []).map(channel);
|
||||
},
|
||||
|
||||
async messages(channelId: string): Promise<Message[]> {
|
||||
if (useMock) return [...(mockState.messages[channelId] ?? [])];
|
||||
async channelWindow({
|
||||
channelId,
|
||||
cursor = null,
|
||||
}: ChannelWindowRequest): Promise<RawEvent[]> {
|
||||
if (useMock) {
|
||||
return [
|
||||
...(mockState.messages[channelId] ?? []).map((message) => ({
|
||||
id: message.id,
|
||||
pubkey: message.pubkey,
|
||||
content: message.content,
|
||||
created_at: message.createdAt,
|
||||
kind: message.kind,
|
||||
tags: message.tags,
|
||||
})),
|
||||
{
|
||||
id: `bounds-${channelId}`,
|
||||
pubkey: "relay",
|
||||
content: '{"has_more":false,"next_cursor":null}',
|
||||
created_at: 0,
|
||||
kind: 39006,
|
||||
tags: [
|
||||
["h", channelId],
|
||||
["d", `${channelId}:head`],
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
const events = await invoke<RawEvent[]>("get_channel_window", {
|
||||
channelId,
|
||||
limitRows: 80,
|
||||
cursor: null,
|
||||
cursor: cursor
|
||||
? { created_at: cursor.createdAt, event_id: cursor.id }
|
||||
: null,
|
||||
});
|
||||
return projectChannelWindow(events).messages;
|
||||
return events;
|
||||
},
|
||||
|
||||
async participants(channelId: string): Promise<Participant[]> {
|
||||
|
||||
14
desktop-new/src/shared/runtime/observerEvent.ts
Normal file
14
desktop-new/src/shared/runtime/observerEvent.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Raw owner-private activity delivered by the runtime adapter. Product
|
||||
* projections belong to Agent Activity; this transport shape does not.
|
||||
*/
|
||||
export type ObserverEvent = {
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
kind: string;
|
||||
channelId?: string;
|
||||
sessionId?: string;
|
||||
turnId?: string;
|
||||
agentPubkey?: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
@@ -4,11 +4,11 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.navigator-row-action.always-visible {
|
||||
.navigation-item-action.always-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.agent-navigator-row {
|
||||
.agent-navigation-item {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -25,9 +25,9 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-navigator-row:hover,
|
||||
.agent-navigator-row[data-selected] {
|
||||
background: var(--bg-inset);
|
||||
.agent-navigation-item:hover,
|
||||
.agent-navigation-item[data-selected] {
|
||||
background: var(--neutral-2);
|
||||
}
|
||||
|
||||
.agent-avatar,
|
||||
@@ -122,7 +122,7 @@
|
||||
gap: 1rem;
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-inset);
|
||||
background: var(--neutral-2);
|
||||
}
|
||||
|
||||
.field-stack {
|
||||
@@ -151,7 +151,7 @@
|
||||
.setup-callout {
|
||||
border-radius: 0.875rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-inset);
|
||||
background: var(--neutral-2);
|
||||
}
|
||||
|
||||
.agent-create-action {
|
||||
|
||||
@@ -39,11 +39,13 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.inline-chip:hover {
|
||||
button.inline-chip:hover,
|
||||
html[data-keyboard-navigation] button.inline-chip:focus-visible,
|
||||
button.inline-chip[data-popup-open] {
|
||||
background: var(--purple-4);
|
||||
}
|
||||
|
||||
.inline-chip:focus-visible {
|
||||
html[data-keyboard-navigation] .inline-chip:focus-visible {
|
||||
outline: 2px solid var(--purple-8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -54,7 +56,7 @@
|
||||
* reads as inert rather than pretending to be actionable.
|
||||
*/
|
||||
.inline-chip[data-state="unresolved"] {
|
||||
background: var(--bg-inset);
|
||||
background: var(--neutral-2);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
|
||||
.buzz-button[data-variant="quiet"] {
|
||||
background: var(--bg-inset);
|
||||
background: var(--neutral-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -50,9 +50,10 @@
|
||||
IconButton light grey while its text stayed white — Lc 13, invisible. An
|
||||
icon button's hover belongs to its icon variant; see below. */
|
||||
.buzz-button[data-variant="quiet"]:hover:not([data-disabled]),
|
||||
.buzz-button[data-variant="ghost"]:not([data-icon-variant]):hover:not(
|
||||
[data-disabled]
|
||||
) {
|
||||
.buzz-button[data-variant="ghost"]:not(
|
||||
[data-icon-variant],
|
||||
[data-navigation-item]
|
||||
):hover:not([data-disabled]) {
|
||||
background: var(--neutral-4);
|
||||
}
|
||||
|
||||
@@ -82,7 +83,7 @@
|
||||
/* Every icon variant states its own hover. The one that did not —
|
||||
`solid` — fell through to the neutral ghost hover and lost its accent. */
|
||||
.buzz-button[data-icon-variant="quiet"] {
|
||||
background: var(--bg-inset);
|
||||
background: var(--neutral-2);
|
||||
}
|
||||
|
||||
.buzz-button[data-icon-variant="quiet"]:hover:not([data-disabled]),
|
||||
@@ -106,6 +107,53 @@
|
||||
@apply glass-primary-interactive;
|
||||
}
|
||||
|
||||
/* A preview is supplemental context, never a second interactive surface.
|
||||
It is portal-rendered by Base UI so a clipped parent cannot hide it. */
|
||||
.buzz-preview-card {
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
width: max-content;
|
||||
max-width: min(18rem, 75vw);
|
||||
gap: var(--space-1);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-control);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-float);
|
||||
box-shadow: var(--shadow-sm);
|
||||
pointer-events: none;
|
||||
transform-origin: var(--transform-origin);
|
||||
transition:
|
||||
opacity var(--duration-state) var(--easing-state),
|
||||
transform var(--duration-state) var(--easing-state);
|
||||
}
|
||||
|
||||
.buzz-preview-card[data-starting-style],
|
||||
.buzz-preview-card[data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.buzz-preview-card-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.buzz-preview-card-example {
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--purple-8);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
html[data-keyboard-navigation] .buzz-preview-card-example:focus-visible {
|
||||
outline: 2px solid var(--purple-8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.buzz-avatar {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -115,7 +163,7 @@
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-inset);
|
||||
background: var(--neutral-2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -150,16 +198,6 @@
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.panel[data-variant="connected-left"] {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.panel[data-variant="connected-right"] {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tabs — two surfaces, one control.
|
||||
|
||||
@@ -385,14 +423,10 @@
|
||||
gap: var(--space-row-gap);
|
||||
border-radius: var(--radius-control);
|
||||
padding: 0 var(--space-control-inset);
|
||||
background: var(--bg-inset);
|
||||
background: var(--neutral-2);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.search-field:focus-within {
|
||||
box-shadow: 0 0 0 2px var(--purple-8);
|
||||
}
|
||||
|
||||
.search-field input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -403,6 +437,11 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
html[data-keyboard-navigation] .search-field input:focus-visible {
|
||||
outline: 2px solid var(--purple-8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.search-field input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
@@ -411,7 +450,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navigator-section {
|
||||
.navigation-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -421,41 +460,76 @@
|
||||
`--space-section-gap` apart, and that difference is the entire signal for
|
||||
where one group ends and the next begins. Expressed as an adjacent-sibling
|
||||
margin so a section stacked anywhere gets it, with no wrapper required. */
|
||||
.navigator-section + .navigator-section {
|
||||
.navigation-section + .navigation-section {
|
||||
margin-top: var(--space-section-gap);
|
||||
}
|
||||
|
||||
.navigator-section-label {
|
||||
.navigation-section-heading {
|
||||
display: flex;
|
||||
min-height: var(--size-row);
|
||||
margin: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-row-gap);
|
||||
padding: 0 var(--space-control-inset);
|
||||
line-height: var(--size-row);
|
||||
}
|
||||
|
||||
.navigator-section-rows {
|
||||
.navigation-section-label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navigation-section-heading .buzz-button[data-icon-size="compact"] {
|
||||
margin-right: calc(var(--space-control-inset) * -1);
|
||||
}
|
||||
|
||||
.navigation-section-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.buzz-button[data-navigator-row] {
|
||||
/* NavigationItem owns its complete visual treatment, although it shares
|
||||
Base UI Button's native button behavior. It does not compose Buzz Button:
|
||||
a navigation destination is not an ordinary action button, and sharing the
|
||||
styling made the generic ghost hover compete with navigation's own states. */
|
||||
.navigation-item {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
height: var(--size-row);
|
||||
align-items: center;
|
||||
gap: var(--space-row-gap);
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: var(--radius-row);
|
||||
padding: 0 var(--space-control-inset);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--duration-state) var(--easing-state),
|
||||
background-color var(--duration-state) var(--easing-state);
|
||||
}
|
||||
|
||||
.buzz-button[data-navigator-row]:hover,
|
||||
.buzz-button[data-navigator-row][data-selected] {
|
||||
background: var(--neutral-4);
|
||||
/* Hover is the lightest acknowledgement; selection is a persistent, one-step
|
||||
stronger statement. Keeping them separate prevents a selected destination
|
||||
from becoming indistinguishable the moment a pointer passes over it. */
|
||||
.navigation-item:hover:not([data-selected]) {
|
||||
background: var(--neutral-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.buzz-button[data-navigator-row][data-inset] {
|
||||
.navigation-item[data-selected] {
|
||||
background: var(--neutral-3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navigation-item[data-inset] {
|
||||
padding-left: calc(var(--space-control-inset) + var(--space-4));
|
||||
}
|
||||
|
||||
.navigator-row-label {
|
||||
.navigation-item-label {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
@@ -463,19 +537,21 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navigator-row-trailing {
|
||||
.navigation-item-trailing {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.buzz-button:focus-visible,
|
||||
.buzz-tabs-tab:focus-visible {
|
||||
html[data-keyboard-navigation] .buzz-button:focus-visible,
|
||||
html[data-keyboard-navigation] .navigation-item:focus-visible,
|
||||
html[data-keyboard-navigation] .buzz-tabs-tab:focus-visible {
|
||||
outline: 2px solid var(--purple-8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.buzz-button,
|
||||
.navigation-item,
|
||||
.buzz-tabs-indicator {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,19 @@
|
||||
A hairline rather than a tinted fill, because the specimen must show the
|
||||
component against the surface it is designed for. Tinting the frame would
|
||||
change what is being judged. */
|
||||
/* A product composition specimen keeps the runtime's bounded dock geometry
|
||||
visible without borrowing the viewport height of the app shell. */
|
||||
.composer-page-specimen {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.composer-page-specimen .conversation-composer-dock {
|
||||
height: 13rem;
|
||||
}
|
||||
|
||||
.component-specimen-frame {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -100,6 +113,7 @@
|
||||
A former two-panel pseudo-workspace was deleted: resize seams and layout
|
||||
geometry belong to DockWorkspace, not to Panel. */
|
||||
.component-single-surface-demo {
|
||||
width: 100%;
|
||||
height: 16rem;
|
||||
padding: var(--space-workspace-inset);
|
||||
border-radius: var(--radius-panel);
|
||||
@@ -114,7 +128,7 @@
|
||||
width: min(100%, var(--size-navigator));
|
||||
}
|
||||
|
||||
.component-navigator-section-demo {
|
||||
.component-navigation-section-demo {
|
||||
width: min(100%, var(--size-navigator));
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-control);
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
@import "./agents.css";
|
||||
|
||||
@layer base {
|
||||
/* Pointer focus has a caret or pressed control to make its destination clear.
|
||||
Chrome also matches :focus-visible for text fields clicked with a pointer,
|
||||
so the app-root modality fact gates every focus treatment consistently. */
|
||||
:focus:not(:focus-visible),
|
||||
html:not([data-keyboard-navigation]) :focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg-app);
|
||||
@@ -146,7 +154,7 @@
|
||||
display: inline-block;
|
||||
padding-inline: 0.375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-inset);
|
||||
background: var(--neutral-2);
|
||||
color: var(--color-text-primary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -149,7 +149,13 @@
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/* Dockview arranges panels; Panel owns the surface treatment. */
|
||||
/* Dockview arranges detached panels. The visible sash is a real workspace
|
||||
gutter, so every Panel inside it keeps its complete rounded silhouette.
|
||||
|
||||
A future zero-gap docked group will own one shared outer frame, clipping,
|
||||
and its internal resize seam. Do not make individual panels infer or remove
|
||||
corners from their position: a drag can create a split, a tab group, or a
|
||||
separated panel, and only the workspace topology knows which it is. */
|
||||
.buzz-dockview .dv-content-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -207,127 +213,75 @@
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
/* The navigator is a quiet scan surface: its search field and first section
|
||||
are separated by space, not a boundary line. PanelHeader owns dividers for
|
||||
titled work surfaces; this local composition deliberately does not use it. */
|
||||
.navigator-heading {
|
||||
min-height: 3.25rem;
|
||||
}
|
||||
|
||||
.navigator-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navigator-search input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
outline: 0;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.navigator-section-label {
|
||||
display: block;
|
||||
padding: 0.25rem 0.625rem 0.5rem;
|
||||
min-height: 0;
|
||||
padding: var(--space-2);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.navigator-list {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.channel-cluster {
|
||||
margin-bottom: 0.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* A Room and its Session action are one visual row. The action remains a
|
||||
separate button for keyboard and pointer users, but it is positioned over
|
||||
the Room item rather than reserving a second grid column that would cut the
|
||||
selected/hover treatment short. */
|
||||
.channel-main-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.navigator-row,
|
||||
.session-child-row,
|
||||
.navigator-row-action {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
.channel-main-row .navigation-item {
|
||||
padding-right: calc(var(--size-row) + var(--space-control-inset));
|
||||
}
|
||||
|
||||
.navigator-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 2.25rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
border-radius: 0.625rem;
|
||||
padding: 0 0.625rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.navigator-row span,
|
||||
.session-child-row span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navigator-row:hover,
|
||||
.session-child-row:hover,
|
||||
.navigator-row-action:hover {
|
||||
background: var(--neutral-4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navigator-row[data-selected],
|
||||
.session-child-row[data-selected] {
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navigator-row-action {
|
||||
display: grid;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
place-items: center;
|
||||
border-radius: 0.5rem;
|
||||
.channel-main-row .buzz-button[data-icon-size="compact"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.channel-main-row:hover .navigator-row-action,
|
||||
.navigator-row-action:focus-visible {
|
||||
.channel-main-row:hover .buzz-button[data-icon-size="compact"],
|
||||
.channel-main-row .buzz-button[data-icon-size="compact"]:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 1.25rem;
|
||||
gap: 1px;
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
|
||||
.session-child-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 2rem;
|
||||
grid-template-columns: 0.75rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
text-align: left;
|
||||
/* This connector expresses the channel → Session relationship and therefore
|
||||
stays in the Messages composition. The selectable Session itself is the
|
||||
shared NavigationItem. */
|
||||
.session-child {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.session-thread-line {
|
||||
width: 0.625rem;
|
||||
height: 0.875rem;
|
||||
.session-child::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
left: calc(var(--space-4) * -0.5);
|
||||
width: calc(var(--space-4) * 0.5);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
border-left: 1px solid var(--border-primary);
|
||||
border-radius: 0 0 0 0.375rem;
|
||||
border-bottom-left-radius: var(--radius-row);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.empty-workspace-panel {
|
||||
@@ -365,7 +319,7 @@
|
||||
.conversation-column {
|
||||
width: min(100%, 47rem);
|
||||
margin: 0 auto;
|
||||
padding: 1.75rem 2rem 7rem;
|
||||
padding: 1.75rem 2rem;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
@@ -381,7 +335,7 @@
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-inset);
|
||||
background: var(--neutral-2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -401,31 +355,84 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.composer-dock {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
left: 1.5rem;
|
||||
margin: auto;
|
||||
/* The bottom dock owns the reserved workspace, not Composer. Its top edge
|
||||
stays put; agent activity consumes room from the dock's lower edge, so the
|
||||
composer shortens from below rather than jumping upward. */
|
||||
.conversation-composer-dock {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
height: 13rem;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
gap: var(--space-1);
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.session-composer {
|
||||
display: grid;
|
||||
/* Activity is an auto-sized grid row, not an estimated absolute rail. Its
|
||||
arrival takes room from the Composer's bottom edge while grid keeps the
|
||||
Composer's top edge fixed. The 4px grid gap is the only separation. */
|
||||
.conversation-activity-rail {
|
||||
display: flex;
|
||||
width: min(100%, 47rem);
|
||||
min-height: 3.5rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
gap: var(--space-2);
|
||||
margin: 0 auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.conversation-activity-rail-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 16rem;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-row);
|
||||
background: var(--neutral-2);
|
||||
}
|
||||
|
||||
/* An authoring surface is inset into the conversation panel, not another
|
||||
floating panel. The boundary is a real input boundary, hence the one
|
||||
shared border role; focus moves it to the palette's focus step. */
|
||||
.message-composer {
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
width: min(100%, 47rem);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
gap: var(--space-2);
|
||||
margin: auto;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 1rem;
|
||||
padding: 0.625rem;
|
||||
background: var(--bg-float);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-radius: var(--radius-control);
|
||||
padding: var(--space-3);
|
||||
background: var(--neutral-2);
|
||||
transition:
|
||||
height var(--duration-settle) var(--easing-settle),
|
||||
border-color var(--duration-state) var(--easing-state),
|
||||
box-shadow var(--duration-state) var(--easing-state);
|
||||
}
|
||||
|
||||
.session-composer textarea {
|
||||
.message-composer[data-state="failed"] {
|
||||
border-color: var(--red-8);
|
||||
}
|
||||
|
||||
.message-composer[data-state="disabled"] {
|
||||
background: var(--neutral-3);
|
||||
}
|
||||
|
||||
.message-composer-input {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.message-composer textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 10rem;
|
||||
min-height: 4.5lh;
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
@@ -435,7 +442,71 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.session-composer button,
|
||||
html[data-keyboard-navigation] .message-composer textarea:focus-visible {
|
||||
outline: 2px solid var(--purple-8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.message-composer textarea::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.message-composer textarea:disabled {
|
||||
color: var(--text-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.message-composer-feedback {
|
||||
margin: 0.25rem 0.5rem 0;
|
||||
}
|
||||
|
||||
.message-composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.message-composer-actions-start,
|
||||
.message-composer-actions-end {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.composer-emoji-picker {
|
||||
z-index: 1000;
|
||||
width: 15rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-control);
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-float);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform-origin: var(--transform-origin);
|
||||
transition:
|
||||
opacity var(--duration-state) var(--easing-state),
|
||||
transform var(--duration-state) var(--easing-state);
|
||||
}
|
||||
|
||||
.composer-emoji-picker[data-starting-style],
|
||||
.composer-emoji-picker[data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.composer-emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.composer-emoji-grid .buzz-button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.icon-button,
|
||||
.quiet-button,
|
||||
.primary-button {
|
||||
@@ -444,7 +515,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.session-composer button,
|
||||
.icon-button {
|
||||
display: grid;
|
||||
width: 2.25rem;
|
||||
@@ -463,7 +533,7 @@
|
||||
}
|
||||
|
||||
.quiet-button {
|
||||
background: var(--bg-inset);
|
||||
background: var(--neutral-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -489,16 +559,19 @@
|
||||
margin: 0.5rem 0 0.75rem 2.625rem;
|
||||
border-radius: 0.875rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid var(--purple-8);
|
||||
outline-offset: 2px;
|
||||
background: var(--neutral-2);
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.conversation-composer-dock {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.message-composer {
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-topbar {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
@@ -282,9 +282,14 @@
|
||||
Keep these current. A step whose comment disagrees with its
|
||||
use is worse than no comment, because it reads as a decision
|
||||
someone made. `pnpm census` lists the real readers. */
|
||||
--neutral-1: #ffffff; /* bg-panel, bg-float, bg-chrome-selected */
|
||||
--neutral-2: #f9f9f9; /* unused in light; bg-inset in dark */
|
||||
--neutral-3: #f0f0f0; /* bg-inset; bg-panel in dark */
|
||||
--neutral-1: #ffffff; /* bg-panel, bg-float, bg-chrome-selected */
|
||||
/* The first real compact control established this stop. Project Orbit's
|
||||
search field reads as #f5f5f6 over its panel. Its one blue-channel step
|
||||
is a deliberately cool near-neutral, not a warm tint; use it directly
|
||||
rather than preserve an exploratory #f9f9f9 stop or recreate it with
|
||||
alpha. */
|
||||
--neutral-2: #f5f5f6; /* quiet compact controls, callouts, and grouping */
|
||||
--neutral-3: #f0f0f0; /* available for a future, genuinely recessed region; bg-panel in dark */
|
||||
--neutral-4: #e8e8e8; /* border-primary in light mode */
|
||||
--neutral-5: #e0e0e0; /* unused in light; float + selected, dark */
|
||||
--neutral-6: #d9d9d9; /* border-primary in dark mode */
|
||||
@@ -483,7 +488,6 @@
|
||||
--bg-app: var(--gradient-1);
|
||||
--bg-panel: var(--neutral-1); /* dark: neutral-3 */
|
||||
--bg-float: var(--neutral-1); /* dark: neutral-5 */
|
||||
--bg-inset: var(--neutral-3); /* dark: neutral-2 */
|
||||
|
||||
/* `bg-hover` was here and is gone. It was neutral-4 in both modes, so
|
||||
`bg-neutral-4` says it exactly. Its five call sites now do.
|
||||
@@ -665,7 +669,6 @@
|
||||
.dark {
|
||||
--bg-panel: var(--neutral-3);
|
||||
--bg-float: var(--neutral-5);
|
||||
--bg-inset: var(--neutral-2);
|
||||
--bg-chrome-selected: var(--neutral-5);
|
||||
|
||||
/* Two steps lighter than light mode's neutral-4, because a hairline needs more
|
||||
@@ -831,7 +834,6 @@
|
||||
--color-app: var(--bg-app);
|
||||
--color-panel: var(--bg-panel);
|
||||
--color-float: var(--bg-float);
|
||||
--color-inset: var(--bg-inset);
|
||||
|
||||
/* TEXT COLOUR REGISTERS IN ITS OWN NAMESPACE, like borders below.
|
||||
Not `--color-*`.
|
||||
|
||||
@@ -320,13 +320,6 @@ export const ROLE_GROUPS: RoleGroup[] = [
|
||||
use: "Anything hovering above the page: menus, dialogs, tooltips, toasts. Shares a light value with bg-panel and diverges in dark, because a shadow cannot carry elevation on a near-black background.",
|
||||
status: "core",
|
||||
},
|
||||
{
|
||||
token: "bg-inset",
|
||||
variable: "--bg-inset",
|
||||
pointsAt: "neutral 3 light / neutral 2 dark",
|
||||
use: "Anything pushed in: inputs, code blocks, quotes.",
|
||||
status: "core",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,28 +8,14 @@ import {
|
||||
chipFaces,
|
||||
} from "@/shared/chips/faceResolver";
|
||||
|
||||
/**
|
||||
* A reference to a person, agent, channel, message, or link, shown inline in a
|
||||
* sentence.
|
||||
*
|
||||
* One component serves both the composer and the conversation. Two
|
||||
* implementations of one face drift, and a reference that looks different
|
||||
* depending on whether it is being written or being read is the kind of
|
||||
* disconnected feature this client exists to avoid.
|
||||
*
|
||||
* A chip is an object, not styled text: the caret cannot sit inside it, one
|
||||
* deletion removes it whole, and it carries its identity rather than its name.
|
||||
* The label is resolved from the address at render time, so a rename updates
|
||||
* every chip without editing anyone's draft.
|
||||
*/
|
||||
import { PreviewCard } from "./PreviewCard";
|
||||
|
||||
/**
|
||||
* Icons only for the kinds a reader cannot identify from the label.
|
||||
*
|
||||
* A person and an agent are introduced by `@`, a channel by `#` — those sigils
|
||||
* are the identification, so adding a glyph would say the same thing twice. A
|
||||
* message and a link have no such convention.
|
||||
* A reference to a person, agent, channel, message, or link, shown inline in a
|
||||
* sentence. A resolved chip composes PreviewCard; `onActivate` is reserved for
|
||||
* a surface that owns an explicit persistent view.
|
||||
*/
|
||||
|
||||
const KIND_ICON: Partial<Record<ChipKind, typeof IconLink>> = {
|
||||
message: IconMessageCircle,
|
||||
link: IconLink,
|
||||
@@ -50,15 +36,17 @@ export function InlineChip({
|
||||
}: {
|
||||
address: ChipAddress;
|
||||
/**
|
||||
* Whether the chip is a control. Read-only contexts pass false so a chip
|
||||
* does not claim an interactive screen-reader stop it cannot honour.
|
||||
* An inert rendering for places where even a preview would compete with the
|
||||
* surrounding interaction, such as a future editable document boundary.
|
||||
*/
|
||||
interactive?: boolean;
|
||||
/** An explicit owner-provided action; previews never imply a deep-open. */
|
||||
onActivate?: (address: ChipAddress) => void;
|
||||
}) {
|
||||
const face = useChipFace(address);
|
||||
const Icon = KIND_ICON[address.kind];
|
||||
const trigger = CHIP_KIND_TRIGGER[address.kind];
|
||||
const kind = accessibleKind(address.kind);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
@@ -70,22 +58,17 @@ export function InlineChip({
|
||||
</>
|
||||
);
|
||||
|
||||
// A chip carries one accessible name that states what it refers to. Its icon
|
||||
// is decorative — the label already says it, and a second owner of the same
|
||||
// name produces a duplicate screen-reader stop.
|
||||
//
|
||||
// An unresolved face has no name to announce, and announcing an abbreviated
|
||||
// identity tells a screen-reader user nothing. Say the kind is unresolved
|
||||
// instead; the visible fallback is a recognition aid, not a name.
|
||||
const accessibleName = face.resolved
|
||||
? `${accessibleKind(address.kind)} ${face.label}`
|
||||
: `Unresolved ${accessibleKind(address.kind).toLowerCase()}`;
|
||||
? `${kind} ${face.label}`
|
||||
: `Unresolved ${kind.toLowerCase()}`;
|
||||
const state = face.loading
|
||||
? "loading"
|
||||
: face.resolved
|
||||
? "resolved"
|
||||
: "unresolved";
|
||||
|
||||
const state = face.resolved ? undefined : "unresolved";
|
||||
|
||||
// An unresolved reference has nothing to open, so it is never a control
|
||||
// however it was asked for — a chip that looks actionable and does nothing
|
||||
// is worse than one that looks inert.
|
||||
// An unresolved reference has nothing truthful to preview or open. A chip
|
||||
// that invites an action and does nothing is worse than an inert reference.
|
||||
if (!interactive || !face.resolved) {
|
||||
return (
|
||||
<span
|
||||
@@ -101,18 +84,30 @@ export function InlineChip({
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-chip"
|
||||
data-kind={address.kind}
|
||||
aria-label={accessibleName}
|
||||
// Keeps the caret in the editor. A control that takes focus moves the
|
||||
// caret out and silently breaks every keyboard behaviour after it.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onActivate?.(address)}
|
||||
<PreviewCard
|
||||
trigger={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-chip"
|
||||
data-kind={address.kind}
|
||||
data-state={state}
|
||||
aria-label={accessibleName}
|
||||
onClick={() => onActivate?.(address)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
<span className="buzz-preview-card-kind text-body-sm text-tertiary">
|
||||
{kind}
|
||||
</span>
|
||||
<span className="buzz-preview-card-name text-body text-primary">
|
||||
{face.label}
|
||||
</span>
|
||||
{face.status ? (
|
||||
<span className="text-body-sm text-secondary">{face.status}</span>
|
||||
) : null}
|
||||
</PreviewCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button as BaseButton } from "@base-ui/react/button";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
export function NavigatorRow({
|
||||
export function NavigationItem({
|
||||
label,
|
||||
icon,
|
||||
trailing,
|
||||
@@ -17,20 +17,18 @@ export function NavigatorRow({
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="compact"
|
||||
data-navigator-row=""
|
||||
<BaseButton
|
||||
className="navigation-item"
|
||||
data-selected={selected || undefined}
|
||||
data-inset={inset || undefined}
|
||||
onClick={onClick}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
>
|
||||
{icon}
|
||||
<span className="navigator-row-label">{label}</span>
|
||||
<span className="navigation-item-label">{label}</span>
|
||||
{trailing ? (
|
||||
<span className="navigator-row-trailing">{trailing}</span>
|
||||
<span className="navigation-item-trailing">{trailing}</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</BaseButton>
|
||||
);
|
||||
}
|
||||
23
desktop-new/src/shared/ui/NavigationSection.tsx
Normal file
23
desktop-new/src/shared/ui/NavigationSection.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function NavigationSection({
|
||||
label,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="navigation-section">
|
||||
<div className="navigation-section-heading">
|
||||
<h2 className="navigation-section-label text-body text-secondary">
|
||||
{label}
|
||||
</h2>
|
||||
{action}
|
||||
</div>
|
||||
<div className="navigation-section-rows">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function NavigatorSection({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="navigator-section">
|
||||
<h2 className="navigator-section-label text-body text-secondary">
|
||||
{label}
|
||||
</h2>
|
||||
<div className="navigator-section-rows">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
31
desktop-new/src/shared/ui/PreviewCard.tsx
Normal file
31
desktop-new/src/shared/ui/PreviewCard.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { PreviewCard as BasePreviewCard } from "@base-ui/react/preview-card";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
export type PreviewCardProps = {
|
||||
/** The one interactive element that anchors and reveals this preview. */
|
||||
trigger: ReactElement;
|
||||
/** Context that supplements, but never replaces, the trigger's destination. */
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A non-modal, portal-rendered preview of a resolved object.
|
||||
*
|
||||
* Base UI owns the hover/focus timing, anchored positioning, viewport collision,
|
||||
* and portal lifecycle. The preview stays supplemental: it does not take focus
|
||||
* or contain an action of its own.
|
||||
*/
|
||||
export function PreviewCard({ trigger, children }: PreviewCardProps) {
|
||||
return (
|
||||
<BasePreviewCard.Root>
|
||||
<BasePreviewCard.Trigger render={trigger} delay={250} closeDelay={150} />
|
||||
<BasePreviewCard.Portal>
|
||||
<BasePreviewCard.Positioner side="bottom" align="start" sideOffset={8}>
|
||||
<BasePreviewCard.Popup className="buzz-preview-card" role="tooltip">
|
||||
{children}
|
||||
</BasePreviewCard.Popup>
|
||||
</BasePreviewCard.Positioner>
|
||||
</BasePreviewCard.Portal>
|
||||
</BasePreviewCard.Root>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,20 @@ describe("component registry — Base UI backing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("places documented child components beneath a real parent", () => {
|
||||
for (const component of COMPONENTS) {
|
||||
if (!component.parent) continue;
|
||||
const parent = COMPONENTS.find(
|
||||
(candidate) => candidate.slug === component.parent,
|
||||
);
|
||||
expect(parent, `${component.slug} parent`).toBeDefined();
|
||||
expect(
|
||||
parent?.parent,
|
||||
`${component.slug} parent is top-level`,
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("claims exactly the Base UI modules its source imports", () => {
|
||||
for (const component of COMPONENTS) {
|
||||
const source = read(component.source);
|
||||
|
||||
@@ -23,6 +23,11 @@ export const BASE_UI_PARTS = {
|
||||
module: "@base-ui/react/accordion",
|
||||
},
|
||||
dialog: { name: "Dialog", docs: "dialog", module: "@base-ui/react/dialog" },
|
||||
previewCard: {
|
||||
name: "Preview Card",
|
||||
docs: "preview-card",
|
||||
module: "@base-ui/react/preview-card",
|
||||
},
|
||||
} as const satisfies Record<string, BaseUiPart>;
|
||||
|
||||
export const BASE_UI_DOCS_ROOT = "https://base-ui.com/react/components";
|
||||
@@ -97,18 +102,40 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
baseUi: [BASE_UI_PARTS.avatar],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "preview-card",
|
||||
name: "PreviewCard",
|
||||
purpose:
|
||||
"A portal-rendered, non-modal preview of an object's already-available context.",
|
||||
behavior: "Base UI Preview Card",
|
||||
variants: ["default"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/PreviewCard.tsx",
|
||||
baseUi: [BASE_UI_PARTS.previewCard],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "inline-chip",
|
||||
name: "InlineChip",
|
||||
purpose:
|
||||
"A reference to a person, agent, channel, message, or link shown inline in a sentence.",
|
||||
behavior: "Semantic native button or image role",
|
||||
variants: ["person", "agent", "channel", "message", "link"],
|
||||
variants: [
|
||||
"person",
|
||||
"agent",
|
||||
"channel",
|
||||
"message",
|
||||
"link",
|
||||
"resolved preview",
|
||||
"loading",
|
||||
"unresolved",
|
||||
],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/InlineChip.tsx",
|
||||
baseUi: [],
|
||||
composes: [],
|
||||
composes: ["preview-card"],
|
||||
},
|
||||
{
|
||||
slug: "panel",
|
||||
@@ -163,28 +190,28 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
|
||||
composes: ["icon-button"],
|
||||
},
|
||||
{
|
||||
slug: "navigator-section",
|
||||
name: "NavigatorSection",
|
||||
slug: "navigation-section",
|
||||
name: "NavigationSection",
|
||||
purpose: "A named group of related rows in a dense workspace navigator.",
|
||||
behavior: "Semantic native section",
|
||||
variants: ["default"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/NavigatorSection.tsx",
|
||||
source: "shared/ui/NavigationSection.tsx",
|
||||
baseUi: [],
|
||||
composes: [],
|
||||
},
|
||||
{
|
||||
slug: "navigator-row",
|
||||
name: "NavigatorRow",
|
||||
slug: "navigation-item",
|
||||
name: "NavigationItem",
|
||||
purpose: "A selectable destination row with optional icon and metadata.",
|
||||
behavior: "Composes Buzz Button",
|
||||
behavior: "Base UI Button",
|
||||
variants: ["default", "inset", "selected"],
|
||||
status: "proposed",
|
||||
owner: "desktop-new Messages",
|
||||
source: "shared/ui/NavigatorRow.tsx",
|
||||
baseUi: [],
|
||||
composes: ["button"],
|
||||
source: "shared/ui/NavigationItem.tsx",
|
||||
baseUi: [BASE_UI_PARTS.button],
|
||||
composes: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -75,15 +75,37 @@ test("an unresolved reference is never a control", async ({ page }) => {
|
||||
await expect(unresolved).toHaveRole("img");
|
||||
});
|
||||
|
||||
test("read-only chips claim no interactive stop", async ({ page }) => {
|
||||
test("inert chips claim no interactive stop", async ({ page }) => {
|
||||
await open(page);
|
||||
|
||||
const readOnly = group(
|
||||
page,
|
||||
"Read-only, as a conversation renders it",
|
||||
).locator(".inline-chip");
|
||||
await expect(readOnly).toHaveRole("img");
|
||||
await expect(readOnly.locator("xpath=self::button")).toHaveCount(0);
|
||||
const inert = group(page, "Inert rendering").locator(".inline-chip");
|
||||
await expect(inert).toHaveRole("img");
|
||||
await expect(inert.locator("xpath=self::button")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a resolved chip previews on pointer and keyboard focus", async ({
|
||||
page,
|
||||
}) => {
|
||||
await open(page);
|
||||
const resolved = group(page, "Resolved preview").locator(".inline-chip");
|
||||
const preview = page.getByRole("tooltip");
|
||||
|
||||
await expect(preview).toHaveCount(0);
|
||||
await resolved.hover();
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview).toContainText("Person");
|
||||
await expect(preview).toContainText("Morgan Martin");
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await resolved.focus();
|
||||
await expect(preview).toBeVisible();
|
||||
});
|
||||
|
||||
test("an unresolved chip has no preview", async ({ page }) => {
|
||||
await open(page);
|
||||
await expect(
|
||||
group(page, "Unresolved reference").getByRole("tooltip"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("activating a chip reports its address, not its label", async ({
|
||||
|
||||
@@ -12,6 +12,99 @@ test("opens a real channel and keeps protocol metadata out of the transcript", a
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/has_more|channel_created/)).toHaveCount(0);
|
||||
|
||||
// The panel is the Dockview content surface. If it shrink-wraps to the
|
||||
// transcript, the composer escapes onto the app backdrop instead of staying
|
||||
// anchored inside the conversation panel.
|
||||
const panelHeight = await page
|
||||
.locator(".dv-active-group .panel")
|
||||
.evaluate((element) => Math.round(element.getBoundingClientRect().height));
|
||||
expect(panelHeight).toBeGreaterThan(500);
|
||||
|
||||
// Dockview leaves a visible resize gutter between these regions. They must
|
||||
// therefore remain independent panels, each with a complete silhouette—not
|
||||
// retain a zero-radius edge from an old fixed connected-pair layout.
|
||||
const panelCorners = await page
|
||||
.locator(".buzz-dockview .panel")
|
||||
.evaluateAll((panels) =>
|
||||
panels.map((panel) => {
|
||||
const style = getComputedStyle(panel);
|
||||
return [
|
||||
style.borderTopLeftRadius,
|
||||
style.borderTopRightRadius,
|
||||
style.borderBottomRightRadius,
|
||||
style.borderBottomLeftRadius,
|
||||
];
|
||||
}),
|
||||
);
|
||||
expect(panelCorners).toEqual([
|
||||
["24px", "24px", "24px", "24px"],
|
||||
["24px", "24px", "24px", "24px"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("sends through the Conversation controller", async ({ page }) => {
|
||||
await page.goto("/?mock=1");
|
||||
const composer = page.getByRole("textbox", { name: "Message the session" });
|
||||
await composer.fill("A real contribution from the controller");
|
||||
await composer.press("Enter");
|
||||
|
||||
// This binds the temporary conversation composition to the controller's
|
||||
// optimistic → accepted lifecycle. The message must settle in the transcript
|
||||
// instead of silently clearing out of the composer.
|
||||
await expect(
|
||||
page.getByText("A real contribution from the controller"),
|
||||
).toBeVisible();
|
||||
await expect(composer).toHaveValue("");
|
||||
});
|
||||
|
||||
test("keeps a deliberate newline in the composer until it is sent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/?mock=1");
|
||||
const composer = page.getByRole("textbox", { name: "Message the session" });
|
||||
await composer.fill("First line");
|
||||
await composer.press("Shift+Enter");
|
||||
await composer.pressSequentially("Second line");
|
||||
|
||||
await expect(composer).toHaveValue("First line\nSecond line");
|
||||
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText("First line\nSecond line")).toBeVisible();
|
||||
await expect(composer).toHaveValue("");
|
||||
});
|
||||
|
||||
test("keeps a conversation draft when a person leaves and returns", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/?mock=1");
|
||||
const composer = page.getByRole("textbox", { name: "Message the session" });
|
||||
await composer.fill("Keep this thought while I check another channel");
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "buzz-interface", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "design", exact: true }).click();
|
||||
|
||||
// A draft belongs to the conversation, not the mounted textarea. Removing
|
||||
// this controller makes the value disappear when the view changes.
|
||||
await expect(composer).toHaveValue(
|
||||
"Keep this thought while I check another channel",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a new-Session draft when a person leaves and returns", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/?mock=1");
|
||||
await page.getByRole("button", { name: "New Session in design" }).click();
|
||||
const composer = page.getByRole("textbox", { name: "Start a Session" });
|
||||
await composer.fill("Keep this Session thought");
|
||||
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await page.getByRole("button", { name: "New Session in design" }).click();
|
||||
|
||||
await expect(composer).toHaveValue("Keep this Session thought");
|
||||
});
|
||||
|
||||
test("starts a Session from its channel without naming ceremony", async ({
|
||||
@@ -35,6 +128,35 @@ test("starts a Session from its channel without naming ceremony", async ({
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the composer usable above live agent activity", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/?mock=1");
|
||||
const dock = page.locator(".conversation-composer-dock");
|
||||
const composer = page.getByRole("textbox", { name: "Message the session" });
|
||||
|
||||
await expect(dock).toHaveAttribute("data-activity", "true");
|
||||
await expect(dock.locator(".conversation-activity-rail")).toContainText(
|
||||
"Vogue is working",
|
||||
);
|
||||
await composer.fill("Keep writing while Vogue works");
|
||||
await expect(composer).toHaveValue("Keep writing while Vogue works");
|
||||
|
||||
const positions = await page.evaluate(() => {
|
||||
const composer = document.querySelector(".message-composer");
|
||||
const rail = document.querySelector(".conversation-activity-rail");
|
||||
if (!composer || !rail) throw new Error("Composer dock is incomplete");
|
||||
return {
|
||||
composerBottom: composer.getBoundingClientRect().bottom,
|
||||
railTop: rail.getBoundingClientRect().top,
|
||||
};
|
||||
});
|
||||
expect(positions.composerBottom).toBeLessThanOrEqual(positions.railTop);
|
||||
// Browser percentage layout can resolve to fractional device pixels; the
|
||||
// designed dock separation is the 4px spacing step, within one CSS pixel.
|
||||
expect(positions.railTop - positions.composerBottom).toBeCloseTo(4, 0);
|
||||
});
|
||||
|
||||
test("keeps owned agent activity inline and collapses earlier steps", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -71,3 +193,73 @@ test("exposes the focused Agents destination without expanding the app scope", a
|
||||
page.getByText("Help with focused product research."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the appearance choice after a reload", async ({ page }) => {
|
||||
await page.goto("/?mock=1");
|
||||
await page.evaluate(() => localStorage.removeItem("buzz-next-color-scheme"));
|
||||
await page.reload();
|
||||
|
||||
await page.getByRole("button", { name: "Use dark mode" }).click();
|
||||
await expect(page.locator("html")).toHaveClass(/dark/);
|
||||
|
||||
// The shell used to hold its own `dark` boolean beside the colour-scheme
|
||||
// fact, so the choice was lost on reload and two owners disagreed.
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveClass(/dark/);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Use light mode" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("inserts emoji at the authored caret and returns to writing", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/?mock=1");
|
||||
const composer = page.getByRole("textbox", { name: "Message the session" });
|
||||
await composer.fill("Hello world");
|
||||
await composer.press("ArrowLeft");
|
||||
await page.getByRole("button", { name: "Choose emoji" }).click();
|
||||
await page.getByRole("button", { name: "Insert 🎉" }).click();
|
||||
|
||||
await expect(composer).toHaveValue("Hello worl🎉d");
|
||||
await expect(composer).toBeFocused();
|
||||
await expect(page.getByRole("button", { name: "Insert 🎉" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("dismisses the emoji picker without losing the composer focus", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/?mock=1");
|
||||
const composer = page.getByRole("textbox", { name: "Message the session" });
|
||||
await composer.fill("Keep writing");
|
||||
await page.getByRole("button", { name: "Choose emoji" }).click();
|
||||
await expect(page.getByRole("button", { name: "Insert 😀" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByRole("button", { name: "Insert 😀" })).toHaveCount(0);
|
||||
await expect(composer).toBeFocused();
|
||||
await expect(composer).toHaveValue("Keep writing");
|
||||
});
|
||||
|
||||
test("shows focus rings only for keyboard navigation", async ({ page }) => {
|
||||
await page.goto("/?mock=1");
|
||||
const composer = page.getByRole("textbox", { name: "Message the session" });
|
||||
const search = page.getByRole("searchbox", {
|
||||
name: "Find a Room, person, or Session",
|
||||
});
|
||||
|
||||
await composer.click();
|
||||
await expect(page.locator("html")).not.toHaveAttribute(
|
||||
"data-keyboard-navigation",
|
||||
);
|
||||
await expect(composer).toHaveCSS("outline-style", "none");
|
||||
|
||||
await search.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator("html")).toHaveAttribute(
|
||||
"data-keyboard-navigation",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "design", exact: true }),
|
||||
).toHaveCSS("outline-style", "solid");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user