Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@
"import": "./dist/web-react/index.js",
"default": "./dist/web-react/index.js"
},
"./assistant": {
"types": "./dist/assistant/index.d.ts",
"import": "./dist/assistant/index.js",
"default": "./dist/assistant/index.js"
},
"./redact": {
"types": "./dist/redact/index.d.ts",
"import": "./dist/redact/index.js",
Expand Down
3 changes: 3 additions & 0 deletions playground/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { BrandHeader } from '@tangle-network/agent-app/brand'
import { CanvasRoute } from './routes/CanvasRoute'
import { TimelineRoute } from './routes/TimelineRoute'
import { ChatRoute } from './routes/ChatRoute'
import { ComposerRoute } from './routes/ComposerRoute'

type ThemeName = 'light' | 'dark'

const ROUTES = [
{ path: '/canvas', label: 'Design' },
{ path: '/timeline', label: 'Storyboard' },
{ path: '/chat', label: 'Agent' },
{ path: '/composer', label: 'Composer' },
] as const

function applyTheme(theme: ThemeName) {
Expand Down Expand Up @@ -85,6 +87,7 @@ export function App() {
{path === '/canvas' && <CanvasRoute />}
{path === '/timeline' && <TimelineRoute />}
{path === '/chat' && <ChatRoute />}
{path === '/composer' && <ComposerRoute />}
</main>
</div>
)
Expand Down
93 changes: 93 additions & 0 deletions playground/src/routes/ComposerRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useState } from 'react'
import {
ChatComposer,
ModelPicker,
type ComposerFile,
} from '@tangle-network/agent-app/web-react'
import { makeModels } from '../fixtures'

/**
* Visual audit for ChatComposer: the shared message input across its states —
* model pill above the box, empty vs typed, streaming (Stop), the attach +
* pending-file surface, and the footer-placement variant. Token-only styling, so
* this is also the proof it themes from the shared tokens (light + dark) without
* any private --chat-* variables.
*/
function Demo({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="space-y-2">
<h3 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{title}
</h3>
<div className="rounded-2xl border border-border bg-card/40 p-4">{children}</div>
</section>
)
}

export function ComposerRoute() {
const models = makeModels()
const [model, setModel] = useState(models[0]!.id)
const pill = (
<ModelPicker value={model} onChange={setModel} models={models} />
)

const pendingFiles: ComposerFile[] = [
{ id: 'f1', name: 'q3-metrics.csv', kind: 'file', status: 'ready' },
{ id: 'f2', name: 'design-assets', kind: 'folder', fileCount: 12, status: 'uploading' },
]

return (
<div className="h-full w-full overflow-y-auto bg-background">
<div className="mx-auto max-w-xl space-y-7 px-6 py-10">
<Demo title="Default — model pill above, empty">
<ChatComposer
onSend={() => {}}
placeholder="Message the assistant…"
controls={pill}
/>
</Demo>

<Demo title="Typed — Send enabled">
<ChatComposer
onSend={() => {}}
placeholder="Message the assistant…"
controls={pill}
initialValue="Create a workflow that reviews opened PRs with a cheap but good model and posts the review as a comment."
/>
</Demo>

<Demo title="Streaming — Send becomes Stop">
<ChatComposer
onSend={() => {}}
onCancel={() => {}}
isStreaming
placeholder="Message the assistant…"
controls={pill}
/>
</Demo>

<Demo title="Attachments — attach button, drag-drop, pending chips">
<ChatComposer
onSend={() => {}}
placeholder="Ask the agent to inspect files…"
controls={pill}
onAttach={() => {}}
onAttachFolder={() => {}}
onRemoveFile={() => {}}
pendingFiles={pendingFiles}
/>
</Demo>

<Demo title="Footer placement — model pill inline (no focus hint)">
<ChatComposer
onSend={() => {}}
placeholder="Message the assistant…"
controls={pill}
controlsPlacement="footer"
focusShortcut={false}
/>
</Demo>
</div>
</div>
)
}
6 changes: 6 additions & 0 deletions playground/terminal-stub.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Dev-only stub for `@tangle-network/sandbox-ui/terminal`. web-react's barrel
// statically pulls workspace-terminal-panel, which lazy-imports the sandbox-ui
// terminal (and its @xterm deps). The playground never renders the terminal, so
// alias it here to keep Vite's optimizer from resolving @xterm.
export const TerminalView = () => null
export default {}
10 changes: 9 additions & 1 deletion playground/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { createRequire } from 'node:module'
import { dirname } from 'node:path'
import { dirname, resolve } from 'node:path'

const require = createRequire(import.meta.url)
// Resolve each peer to the PLAYGROUND's own copy. agent-app is linked via
Expand All @@ -20,9 +20,17 @@ export default defineConfig({
plugins: [react()],
server: { port: 4321 },
preview: { port: 4321 },
// The terminal panel (a lazy, never-rendered import here) drags in @xterm,
// which the playground doesn't install. Exclude agent-app from pre-bundling
// and alias the terminal subpath to a stub so the optimizer doesn't choke.
optimizeDeps: { exclude: ['@tangle-network/agent-app'] },
resolve: {
dedupe: ['react', 'react-dom', 'react-konva', 'konva'],
alias: {
'@tangle-network/sandbox-ui/terminal': resolve(__dirname, 'terminal-stub.js'),
'@xterm/xterm': resolve(__dirname, 'terminal-stub.js'),
'@xterm/addon-fit': resolve(__dirname, 'terminal-stub.js'),
'@xterm/addon-web-links': resolve(__dirname, 'terminal-stub.js'),
react: pkgDir('react'),
'react-dom': pkgDir('react-dom'),
'react-konva': pkgDir('react-konva'),
Expand Down
200 changes: 200 additions & 0 deletions src/assistant/AssistantDock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* Persistent assistant entry point: a floating launcher that opens the chat
* panel as a right-side drawer (full-screen on small viewports), with focus
* trapping and a resizable width. Owns the chat state (via useAssistantChat) so
* the conversation survives the drawer closing — host-shell concerns (the user,
* navigation, balance, money formatting, the graph renderer, and the workflow-
* mutation signal) are injected.
*
* Mount inside an <AssistantClientProvider> (transport) and an
* <AssistantLauncherProvider> (open/seed state).
*/

import { MessageSquare } from "lucide-react";
import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useEffect,
useRef,
} from "react";
import type { ToolDetailRenderers } from "../web-react";
import { AssistantPanel } from "./AssistantPanel";
import { useAssistantLauncher } from "./launcher";
import { ResizeHandle } from "./ResizeHandle";
import type { AssistantTranscriptView } from "./types";
import { useAssistantChat } from "./useAssistantChat";
import { useIsDesktop, usePanelWidth } from "./usePanelPrefs";

export interface AssistantDockProps {
/** The signed-in user this conversation belongs to (null when signed out). */
userId: string | null;
/** Host navigation for error CTAs and connect targets. */
navigate?: (path: string) => void;
balanceUsd?: number | null;
formatMoney?: (usd: number | null) => string;
/** Render workflow YAML as a node graph in a proposal card. */
renderGraph?: (yaml: string) => ReactNode;
/** Called after a workflow-mutating tool is confirmed (host re-fetches its list). */
onWorkflowMutation?: () => void;
/** Markdown renderer for assistant message content (plain text when absent). */
renderMarkdown?: (content: string) => ReactNode;
/** Per-tool custom detail renderers for expanded tool cards in the transcript. */
toolRenderers?: ToolDetailRenderers;
/** Swap the conversation rendering for a host-supplied renderer (see
* {@link AssistantPanelProps.renderTranscript}); the dock chrome, composer,
* transport, and proposal flow stay owned by the panel. */
renderTranscript?: (view: AssistantTranscriptView) => ReactNode;
}

/** Visible, focusable descendants of a container, in tab order. Visibility is
* checked via getClientRects rather than offsetParent, which is null for
* position:fixed elements and would wrongly exclude them. */
function focusableWithin(container: HTMLElement): HTMLElement[] {
const selector =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
return Array.from(container.querySelectorAll<HTMLElement>(selector)).filter(
(el) => el.getClientRects().length > 0,
);
}

export function AssistantDock({
userId,
navigate,
balanceUsd = null,
formatMoney,
renderGraph,
onWorkflowMutation,
renderMarkdown,
toolRenderers,
renderTranscript,
}: AssistantDockProps) {
const { open, openAssistant, closeAssistant } = useAssistantLauncher();
const chat = useAssistantChat(userId, { onWorkflowMutation });

const isDesktop = useIsDesktop();
const { width, maxWidth, setWidth, previewWidth, nudgeWidth } =
usePanelWidth();

const launcherRef = useRef<HTMLButtonElement | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
const wasOpenRef = useRef(false);

// Close on Escape.
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") closeAssistant();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open, closeAssistant]);

// Move focus into the dialog on open; restore it to the opener on close.
useEffect(() => {
if (open) {
if (!wasOpenRef.current && !returnFocusRef.current) {
returnFocusRef.current = document.activeElement as HTMLElement | null;
}
wasOpenRef.current = true;
const el = dialogRef.current;
if (el) (focusableWithin(el)[0] ?? el).focus();
} else if (wasOpenRef.current) {
wasOpenRef.current = false;
const target = returnFocusRef.current?.isConnected
? returnFocusRef.current
: launcherRef.current;
target?.focus();
returnFocusRef.current = null;
}
}, [open]);

const openDialog = () => {
returnFocusRef.current = document.activeElement as HTMLElement | null;
openAssistant();
};

if (!open) {
return (
<button
ref={launcherRef}
type="button"
onClick={openDialog}
aria-label="Open assistant"
className="fixed right-4 bottom-4 z-40 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-colors hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring"
>
<MessageSquare className="h-6 w-6" />
</button>
);
}

// Keep Tab focus within the dialog while it's open.
const trapTab = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key !== "Tab") return;
const dialog = dialogRef.current;
if (!dialog) return;
const focusables = focusableWithin(dialog);
if (focusables.length === 0) {
e.preventDefault();
dialog.focus();
return;
}
// Non-empty here (length === 0 returned above); the index access is safe.
const first = focusables[0]!;
const last = focusables[focusables.length - 1]!;
const active = document.activeElement;
const inside = active instanceof Node && dialog.contains(active);
if (e.shiftKey) {
if (!inside || active === first || active === dialog) {
e.preventDefault();
last.focus();
}
} else if (!inside || active === last) {
e.preventDefault();
first.focus();
}
};

return (
<>
<div
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/40"
onClick={() => closeAssistant()}
/>
<div
ref={dialogRef}
role="dialog"
aria-label="Assistant"
aria-modal="true"
tabIndex={-1}
onKeyDown={trapTab}
style={isDesktop ? { width: `${width}px` } : undefined}
className="fixed inset-y-0 right-0 z-50 flex w-full flex-col border-border border-l shadow-xl focus:outline-none"
>
<AssistantPanel
key={userId ?? "anon"}
chat={chat}
userId={userId}
onClose={() => closeAssistant()}
navigate={navigate}
balanceUsd={balanceUsd}
formatMoney={formatMoney}
renderGraph={renderGraph}
renderMarkdown={renderMarkdown}
toolRenderers={toolRenderers}
renderTranscript={renderTranscript}
/>
{isDesktop && (
<ResizeHandle
width={width}
maxWidth={maxWidth}
onPreview={previewWidth}
onCommit={setWidth}
onNudge={nudgeWidth}
/>
)}
</div>
</>
);
}
Loading