diff --git a/apps/web/bun.lock b/apps/web/bun.lock index 65ebaee96a7..591cefcce23 100644 --- a/apps/web/bun.lock +++ b/apps/web/bun.lock @@ -5,8 +5,11 @@ "": { "name": "@vellumai/web", "dependencies": { + "@hey-api/client-fetch": "^0.13.1", "@tanstack/react-query": "5.90.21", "@vellum/design-library": "file:../../packages/design-library", + "lucide-react": "^1.16.0", + "motion": "^12.39.0", "react": "19.2.6", "react-dom": "19.2.6", "react-router": "7.15.0", @@ -48,6 +51,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "@hey-api/client-fetch": ["@hey-api/client-fetch@0.13.1", "", { "peerDependencies": { "@hey-api/openapi-ts": "< 2" } }, "sha512-29jBRYNdxVGlx5oewFgOrkulZckpIpBIRHth3uHFn1PrL2ucMy52FvWOY3U3dVx2go1Z3kUmMi6lr07iOpUqqA=="], + "@hey-api/codegen-core": ["@hey-api/codegen-core@0.8.1", "", { "dependencies": { "@hey-api/types": "0.1.4", "ansi-colors": "4.1.3", "c12": "3.3.4", "color-support": "1.1.3" } }, "sha512-Iciv2vUCJTW9lWM/ROvyZLblmcbYJHPuXfzb1SzeDVVn4xEXu2ilLU1pq3fn+09FZ/Y0P7VyvRE47UDU6om8xA=="], "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.4.2", "", { "dependencies": { "@jsdevtools/ono": "7.1.3", "@types/json-schema": "7.0.15", "js-yaml": "4.1.1" } }, "sha512-ZhCFSKI2ipZHEbgmtUHdyddvRU3wJ4elgCfYUC7T7hZa4EivSrVflTQf2w+v3TuaYxR1Y2V2kq3otqTttrrK8Q=="], @@ -280,6 +285,8 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "framer-motion": ["framer-motion@12.39.0", "", { "dependencies": { "motion-dom": "^12.39.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], @@ -348,6 +355,8 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lucide-react": ["lucide-react@1.16.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -356,6 +365,12 @@ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "motion": ["motion@12.39.0", "", { "dependencies": { "framer-motion": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-H4a+Ze+a9j+/NTla5ezfb/g9vmIOxC+viDj++NGDZyTZkdRKjiOz3kSv6TalRWM8ZmD2y/CfC6TkQc97ybyqSA=="], + + "motion-dom": ["motion-dom@12.39.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ=="], + + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], diff --git a/apps/web/package.json b/apps/web/package.json index cfc2f09e345..daf607b00ca 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,8 +12,11 @@ "openapi-ts": "openapi-ts" }, "dependencies": { + "@hey-api/client-fetch": "0.13.1", "@tanstack/react-query": "5.90.21", "@vellum/design-library": "file:../../packages/design-library", + "lucide-react": "1.16.0", + "motion": "12.39.0", "react": "19.2.6", "react-dom": "19.2.6", "react-router": "7.15.0" diff --git a/apps/web/src/components/avatar/animated-avatar.tsx b/apps/web/src/components/avatar/animated-avatar.tsx new file mode 100644 index 00000000000..f2ccf072d2a --- /dev/null +++ b/apps/web/src/components/avatar/animated-avatar.tsx @@ -0,0 +1,307 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useReducedMotion } from "motion/react"; + +import { computeTransforms, resolveDefinitions } from "@/domains/avatar/svg-compositor.js"; +import type { CharacterComponents, CharacterTraits } from "@/domains/avatar/types.js"; + +interface AnimatedAvatarProps { + components: CharacterComponents; + traits: CharacterTraits; + size: number; + isStreaming?: boolean; +} + +function randomBetween(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +// SVG path wobble — port of macOS EditablePath.wobbled() + +interface PathPoint { + x: number; + y: number; +} + +function parsePathNumbers(d: string): number[] { + const nums: number[] = []; + const re = /-?\d+\.?\d*(?:e[+-]?\d+)?/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(d)) !== null) { + nums.push(parseFloat(m[0])); + } + return nums; +} + +function computeCentroid(d: string): PathPoint { + const nums = parsePathNumbers(d); + let sx = 0; + let sy = 0; + let count = 0; + for (let i = 0; i < nums.length - 1; i += 2) { + sx += nums[i]!; + sy += nums[i + 1]!; + count++; + } + return count > 0 ? { x: sx / count, y: sy / count } : { x: 0, y: 0 }; +} + +function wobblePath(d: string, seed: number, amount: number): string { + const center = computeCentroid(d); + const phase = seed * 1.1; + + return d.replace(/-?\d+\.?\d*(?:e[+-]?\d+)?/gi, (match, offset: number) => { + const val = parseFloat(match); + const prevText = d.slice(0, offset); + const numsBefore = prevText.match(/-?\d+\.?\d*(?:e[+-]?\d+)?/gi); + const idx = numsBefore ? numsBefore.length : 0; + const isX = idx % 2 === 0; + + const refVal = isX ? center.x : center.y; + const otherNums = parsePathNumbers(d); + const pairedIdx = isX ? idx + 1 : idx - 1; + const pairedVal = + pairedIdx >= 0 && pairedIdx < otherNums.length + ? otherNums[pairedIdx]! + : refVal; + + const px = isX ? val : pairedVal; + const py = isX ? pairedVal : val; + + const angle = Math.atan2(py - center.y, px - center.x); + const wobble = + Math.sin(angle * 2.0 + phase) * 0.7 + + Math.sin(angle * 3.0 - phase * 0.5) * 0.3; + const scale = 1.0 + wobble * amount; + + const result = refVal + (val - refVal) * scale; + return result.toFixed(3); + }); +} + +function precomputeWobbledPaths( + basePath: string, + count: number, + amount: number, +): string[] { + const paths: string[] = [basePath]; + for (let i = 1; i < count; i++) { + paths.push(wobblePath(basePath, i, amount)); + } + return paths; +} + +/** + * Character avatar rendered as React SVG elements with idle animations: + * - Breathing: continuous 4s scale pulse (CSS keyframe) + * - Blink: random 3-7s eye scaleY squish, 20% double-blink + * - Twitch: random 8-15s body rotation wobble + * + * During streaming (`isStreaming`): + * - Morph: body path cycles through 16 wobbled variants + * - Scale + rotation CSS animations + * - Blink + twitch paused + * + * All animations respect `prefers-reduced-motion`. + */ +export function AnimatedAvatar({ + components, + traits, + size, + isStreaming = false, +}: AnimatedAvatarProps) { + const reduce = useReducedMotion(); + + const { bodyShape, eyeStyle, color } = resolveDefinitions( + components, + traits.bodyShape, + traits.eyeStyle, + traits.color, + ); + const { bodyTransform, eyeTransform } = computeTransforms( + bodyShape, + eyeStyle, + components, + size, + ); + + const eyeVB = eyeStyle.sourceViewBox; + const bodyVB = bodyShape.viewBox; + const bodyScaleFactor = Math.min(size / bodyVB.width, size / bodyVB.height); + const bodyTx = (size - bodyVB.width * bodyScaleFactor) / 2; + const bodyTy = (size - bodyVB.height * bodyScaleFactor) / 2; + const remapScale = Math.min( + bodyVB.width / eyeVB.width, + bodyVB.height / eyeVB.height, + ); + + const override = components.faceCenterOverrides.find( + (o) => o.bodyShape === bodyShape.id && o.eyeStyle === eyeStyle.id, + ); + const faceCenter = override ? override.faceCenter : bodyShape.faceCenter; + const remapTx = faceCenter.x - eyeStyle.eyeCenter.x * remapScale; + const remapTy = faceCenter.y - eyeStyle.eyeCenter.y * remapScale; + + const eyeCenterOutputX = + bodyScaleFactor * (remapTx + eyeStyle.eyeCenter.x * remapScale) + bodyTx; + const eyeCenterOutputY = + bodyScaleFactor * (remapTy + eyeStyle.eyeCenter.y * remapScale) + bodyTy; + + const morphPaths = useMemo( + () => precomputeWobbledPaths(bodyShape.svgPath, 16, 0.06), + [bodyShape.svgPath], + ); + + const [isBlinking, setIsBlinking] = useState(false); + const [twitchAngle, setTwitchAngle] = useState(0); + const [morphIndex, setMorphIndex] = useState(0); + + const blinkTimerRef = useRef | null>(null); + const twitchTimerRef = useRef | null>(null); + const morphTimerRef = useRef | null>(null); + const mountedRef = useRef(true); + + // Blink logic (paused during streaming) + useEffect(() => { + if (reduce || isStreaming) return; + mountedRef.current = true; + + function scheduleBlink() { + blinkTimerRef.current = setTimeout(() => { + if (!mountedRef.current) return; + setIsBlinking(true); + setTimeout(() => { + if (!mountedRef.current) return; + setIsBlinking(false); + if (Math.random() < 0.2) { + setTimeout(() => { + if (!mountedRef.current) return; + setIsBlinking(true); + setTimeout(() => { + if (!mountedRef.current) return; + setIsBlinking(false); + scheduleBlink(); + }, 150); + }, 200); + } else { + scheduleBlink(); + } + }, 150); + }, randomBetween(3000, 7000)); + } + + scheduleBlink(); + + return () => { + mountedRef.current = false; + if (blinkTimerRef.current) clearTimeout(blinkTimerRef.current); + }; + }, [reduce, isStreaming]); + + // Twitch logic (paused during streaming) + useEffect(() => { + if (reduce || isStreaming) return; + mountedRef.current = true; + + function scheduleTwitch() { + twitchTimerRef.current = setTimeout(() => { + if (!mountedRef.current) return; + const angle = + (Math.random() < 0.5 ? -1 : 1) * randomBetween(1, 2); + setTwitchAngle(angle); + setTimeout(() => { + if (!mountedRef.current) return; + setTwitchAngle(0); + scheduleTwitch(); + }, 200); + }, randomBetween(8000, 15000)); + } + + scheduleTwitch(); + + return () => { + mountedRef.current = false; + if (twitchTimerRef.current) clearTimeout(twitchTimerRef.current); + }; + }, [reduce, isStreaming]); + + // Morph path cycling (only during streaming) + useEffect(() => { + if (!isStreaming || reduce) { + setMorphIndex(0); + return; + } + + let idx = 0; + morphTimerRef.current = setInterval(() => { + idx = (idx + 1) % morphPaths.length; + setMorphIndex(idx); + }, 150); + + return () => { + if (morphTimerRef.current) clearInterval(morphTimerRef.current); + morphTimerRef.current = null; + }; + }, [isStreaming, reduce, morphPaths.length]); + + const bodyCenterX = size / 2; + const bodyCenterY = size / 2; + + const breatheAnimation = reduce + ? "none" + : isStreaming + ? "avatar-morph-scale 2.4s ease-in-out infinite, avatar-morph-rotate 3s ease-in-out infinite" + : "avatar-breathe-kf 4s ease-in-out infinite"; + + const effectiveTwitchAngle = isStreaming ? 0 : twitchAngle; + const currentBodyPath = morphPaths[morphIndex] ?? bodyShape.svgPath; + + return ( + + + + + + + {eyeStyle.paths.map((p, i) => ( + + ))} + + + ); +} diff --git a/apps/web/src/components/avatar/chat-avatar.tsx b/apps/web/src/components/avatar/chat-avatar.tsx new file mode 100644 index 00000000000..2337725c9e5 --- /dev/null +++ b/apps/web/src/components/avatar/chat-avatar.tsx @@ -0,0 +1,137 @@ +import { motion, useReducedMotion } from "motion/react"; +import { useCallback, useMemo, useState } from "react"; + +import type { CharacterComponents, CharacterTraits } from "@/domains/avatar/types.js"; +import { AnimatedAvatar } from "./animated-avatar.js"; + +export interface ChatAvatarProps { + components: CharacterComponents | null; + traits: CharacterTraits | null; + customImageUrl: string | null; + size?: number; + className?: string; + interactive?: boolean; + isStreaming?: boolean; +} + +/** + * Displays the assistant's avatar in chat messages. + * + * Priority: + * 1. Animated character avatar from saved traits + * 2. Custom uploaded image + * 3. Default animated character avatar from first component of each type + * 4. Vellum "V" fallback + * + * Animation: + * - Mount plays an entrance spring (scale 0.6 → 1, opacity 0 → 1). + * - When `interactive`, click triggers a spring bounce. + * - `prefers-reduced-motion` short-circuits both. + */ +export function ChatAvatar({ + components, + traits, + customImageUrl, + size = 28, + className, + interactive = false, + isStreaming = false, +}: ChatAvatarProps) { + const reduce = useReducedMotion(); + const [isPoking, setIsPoking] = useState(false); + + const triggerBounce = useCallback(() => { + if (reduce) return; + setIsPoking(true); + window.setTimeout(() => setIsPoking(false), 360); + }, [reduce]); + + const handleClick = interactive ? triggerBounce : undefined; + + const effectiveTraits = useMemo(() => { + if (traits) return traits; + if (!components) return null; + const body = components.bodyShapes[0]; + const eyes = components.eyeStyles[0]; + const color = components.colors[0]; + if (!body || !eyes || !color) return null; + return { bodyShape: body.id, eyeStyle: eyes.id, color: color.id }; + }, [traits, components]); + + const hasCharacter = !!components && !!effectiveTraits; + const preferCharacter = hasCharacter && (!!traits || !customImageUrl); + + const wrapperStyle: React.CSSProperties = { + width: size, + height: size, + flexShrink: 0, + cursor: interactive ? "pointer" : undefined, + transformOrigin: "center", + }; + + const transition = reduce + ? { duration: 0 } + : { type: "spring" as const, visualDuration: 0.3, bounce: 0.5 }; + + const initial = reduce + ? { scale: 1, opacity: 1 } + : { scale: 0.6, opacity: 0 }; + const animate = { scale: isPoking ? 1.15 : 1, opacity: 1 }; + + if (preferCharacter) { + return ( + + + + ); + } + + if (customImageUrl) { + return ( + + Assistant avatar + + ); + } + + return ( + + V + + ); +} diff --git a/apps/web/src/components/resizable-panel.tsx b/apps/web/src/components/resizable-panel.tsx new file mode 100644 index 00000000000..36906650d01 --- /dev/null +++ b/apps/web/src/components/resizable-panel.tsx @@ -0,0 +1,159 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; + +import { cn } from "@vellum/design-library/utils/cn"; + +export interface ResizablePanelProps { + left: ReactNode; + right: ReactNode; + defaultLeftWidth?: number; + minLeftWidth?: number; + minRightWidth?: number; + onWidthChange?: (leftWidth: number) => void; + storageKey?: string; + className?: string; +} + +/** + * Horizontal split-view with a draggable divider. + * + * Uses pointer events with `setPointerCapture` for reliable cross-browser + * drag tracking. No external resizable library is used. + */ +export function ResizablePanel({ + left, + right, + defaultLeftWidth = 400, + minLeftWidth = 300, + minRightWidth = 300, + onWidthChange, + storageKey, + className, +}: ResizablePanelProps) { + const containerRef = useRef(null); + + const [leftWidth, setLeftWidth] = useState(() => { + if (storageKey) { + try { + const stored = localStorage.getItem(storageKey); + if (stored != null) { + const parsed = Number(stored); + if (Number.isFinite(parsed)) return parsed; + } + } catch { + // localStorage access can throw under strict-privacy contexts. + } + } + return defaultLeftWidth; + }); + + const dragRef = useRef<{ startX: number; startWidth: number } | null>(null); + const [isDragging, setIsDragging] = useState(false); + + const clamp = useCallback( + (width: number) => { + const container = containerRef.current; + if (!container) return width; + const maxLeft = container.offsetWidth - minRightWidth; + return Math.max(minLeftWidth, Math.min(width, maxLeft)); + }, + [minLeftWidth, minRightWidth], + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + dragRef.current = { startX: e.clientX, startWidth: leftWidth }; + setIsDragging(true); + }, + [leftWidth], + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragRef.current) return; + const delta = e.clientX - dragRef.current.startX; + const next = clamp(dragRef.current.startWidth + delta); + setLeftWidth(next); + onWidthChange?.(next); + }, + [clamp, onWidthChange], + ); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + if (!dragRef.current) return; + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + const finalWidth = clamp( + dragRef.current.startWidth + (e.clientX - dragRef.current.startX), + ); + dragRef.current = null; + setIsDragging(false); + + if (storageKey) { + try { + localStorage.setItem(storageKey, String(finalWidth)); + } catch { + // Storage quota or security error — ignore. + } + } + }, + [clamp, storageKey], + ); + + useEffect(() => { + function onResize() { + setLeftWidth((prev) => clamp(prev)); + } + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [clamp]); + + return ( +
+ {/* Left pane */} +
+ {left} +
+ + {/* Divider */} +
+
+
+
+ + {/* Right pane */} +
+ {right} +
+
+ ); +} diff --git a/apps/web/src/domains/avatar/api.ts b/apps/web/src/domains/avatar/api.ts new file mode 100644 index 00000000000..f66eef5f1c2 --- /dev/null +++ b/apps/web/src/domains/avatar/api.ts @@ -0,0 +1,60 @@ +/** + * Avatar API functions for fetching character components and traits. + * + * These call daemon endpoints via the configured HeyAPI client singleton. + */ +import { client } from "@/lib/api-client.js"; +import { assertHasResponse } from "@/lib/api-errors.js"; +import type { CharacterComponents, CharacterTraits } from "./types.js"; +import { isCharacterTraits } from "./types.js"; + +export async function fetchCharacterComponents( + assistantId: string, +): Promise { + try { + const { data, error, response } = await client.get({ + url: "/v1/assistants/{assistant_id}/avatar/character-components", + path: { assistant_id: assistantId }, + }); + assertHasResponse(response, error, "Failed to fetch character components"); + if (!response.ok || !data || typeof data !== "object") return null; + return data as CharacterComponents; + } catch { + return null; + } +} + +export async function fetchCharacterTraits( + assistantId: string, +): Promise { + try { + const { data, error, response } = await client.get({ + url: "/v1/assistants/{assistant_id}/avatar/character-traits", + path: { assistant_id: assistantId }, + }); + assertHasResponse(response, error, "Failed to fetch character traits"); + if (!response.ok || !data) return null; + if (!isCharacterTraits(data)) return null; + return data; + } catch { + return null; + } +} + +export async function fetchAvatarImageUrl( + assistantId: string, +): Promise { + try { + const { data, error, response } = await client.get({ + url: "/v1/assistants/{assistant_id}/workspace/file/content/", + path: { assistant_id: assistantId }, + query: { path: "data/avatar/avatar-image.png" }, + parseAs: "blob", + }); + assertHasResponse(response, error, "Failed to fetch avatar image"); + if (!response.ok || !data) return null; + return URL.createObjectURL(data as Blob); + } catch { + return null; + } +} diff --git a/apps/web/src/domains/avatar/svg-compositor.ts b/apps/web/src/domains/avatar/svg-compositor.ts new file mode 100644 index 00000000000..4c35df80c68 --- /dev/null +++ b/apps/web/src/domains/avatar/svg-compositor.ts @@ -0,0 +1,70 @@ +import type { + BodyShapeDefinition, + CharacterComponents, + ColorDefinition, + EyeStyleDefinition, +} from "./types.js"; + +export interface AvatarTransforms { + bodyTransform: string; + eyeTransform: string; +} + +/** + * Compute the SVG transform strings for body and eye groups. + */ +export function computeTransforms( + bodyShape: BodyShapeDefinition, + eyeStyle: EyeStyleDefinition, + components: CharacterComponents, + size: number, +): AvatarTransforms { + const override = components.faceCenterOverrides.find( + (o) => o.bodyShape === bodyShape.id && o.eyeStyle === eyeStyle.id, + ); + const faceCenter = override ? override.faceCenter : bodyShape.faceCenter; + + const bodyVB = bodyShape.viewBox; + const bodyScale = Math.min(size / bodyVB.width, size / bodyVB.height); + const bodyTx = (size - bodyVB.width * bodyScale) / 2; + const bodyTy = (size - bodyVB.height * bodyScale) / 2; + + const eyeVB = eyeStyle.sourceViewBox; + const remapScale = Math.min( + bodyVB.width / eyeVB.width, + bodyVB.height / eyeVB.height, + ); + const remapTx = faceCenter.x - eyeStyle.eyeCenter.x * remapScale; + const remapTy = faceCenter.y - eyeStyle.eyeCenter.y * remapScale; + + const composedScale = bodyScale * remapScale; + const composedTx = bodyScale * remapTx + bodyTx; + const composedTy = bodyScale * remapTy + bodyTy; + + return { + bodyTransform: `matrix(${bodyScale},0,0,${bodyScale},${bodyTx},${bodyTy})`, + eyeTransform: `matrix(${composedScale},0,0,${composedScale},${composedTx},${composedTy})`, + }; +} + +/** + * Resolve the active definitions from components + trait IDs. + */ +export function resolveDefinitions( + components: CharacterComponents, + bodyShapeId: string, + eyeStyleId: string, + colorId: string, +): { + bodyShape: BodyShapeDefinition; + eyeStyle: EyeStyleDefinition; + color: ColorDefinition; +} { + const bodyShape = components.bodyShapes.find((b) => b.id === bodyShapeId); + if (!bodyShape) throw new Error(`Unknown body shape: "${bodyShapeId}"`); + const eyeStyle = components.eyeStyles.find((e) => e.id === eyeStyleId); + if (!eyeStyle) throw new Error(`Unknown eye style: "${eyeStyleId}"`); + const color = components.colors.find((c) => c.id === colorId); + if (!color) throw new Error(`Unknown color: "${colorId}"`); + return { bodyShape, eyeStyle, color }; +} diff --git a/apps/web/src/domains/avatar/types.ts b/apps/web/src/domains/avatar/types.ts new file mode 100644 index 00000000000..2776dcbc756 --- /dev/null +++ b/apps/web/src/domains/avatar/types.ts @@ -0,0 +1,52 @@ +export interface BodyShapeDefinition { + id: string; + viewBox: { width: number; height: number }; + faceCenter: { x: number; y: number }; + svgPath: string; +} + +export interface EyePathDefinition { + svgPath: string; + color: string; +} + +export interface EyeStyleDefinition { + id: string; + sourceViewBox: { width: number; height: number }; + eyeCenter: { x: number; y: number }; + paths: EyePathDefinition[]; +} + +export interface ColorDefinition { + id: string; + hex: string; +} + +export interface FaceCenterOverride { + bodyShape: string; + eyeStyle: string; + faceCenter: { x: number; y: number }; +} + +export interface CharacterComponents { + bodyShapes: BodyShapeDefinition[]; + eyeStyles: EyeStyleDefinition[]; + colors: ColorDefinition[]; + faceCenterOverrides: FaceCenterOverride[]; +} + +export interface CharacterTraits { + bodyShape: string; + eyeStyle: string; + color: string; +} + +export function isCharacterTraits(value: unknown): value is CharacterTraits { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return ( + typeof obj.bodyShape === "string" && + typeof obj.eyeStyle === "string" && + typeof obj.color === "string" + ); +} diff --git a/apps/web/src/domains/avatar/use-assistant-avatar.ts b/apps/web/src/domains/avatar/use-assistant-avatar.ts new file mode 100644 index 00000000000..2647fbbc462 --- /dev/null +++ b/apps/web/src/domains/avatar/use-assistant-avatar.ts @@ -0,0 +1,75 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + fetchCharacterComponents, + fetchCharacterTraits, + fetchAvatarImageUrl, +} from "./api.js"; +import type { CharacterComponents, CharacterTraits } from "./types.js"; + +interface AvatarData { + components: CharacterComponents | null; + traits: CharacterTraits | null; + customImageUrl: string | null; +} + +const AVATAR_QUERY_KEY_PREFIX = "assistantAvatar"; + +function avatarQueryKey(assistantId: string) { + return [AVATAR_QUERY_KEY_PREFIX, assistantId] as const; +} + +const activeBlobUrls = new Map(); + +/** + * Shared hook for assistant avatar data backed by React Query. + * + * All consumers of the same `assistantId` share a single cached result. + * Call `invalidate()` to trigger a refetch that every consumer sees. + */ +export function useAssistantAvatar(assistantId: string | null) { + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: avatarQueryKey(assistantId ?? ""), + queryFn: async () => { + const id = assistantId!; + const [components, traits, imageUrl] = await Promise.all([ + fetchCharacterComponents(id), + fetchCharacterTraits(id), + fetchAvatarImageUrl(id), + ]); + + const prev = activeBlobUrls.get(id); + if (prev && prev !== imageUrl) { + URL.revokeObjectURL(prev); + } + if (imageUrl) { + activeBlobUrls.set(id, imageUrl); + } else { + activeBlobUrls.delete(id); + } + + return { components, traits, customImageUrl: imageUrl }; + }, + enabled: Boolean(assistantId), + staleTime: Infinity, + structuralSharing: false, + }); + + const invalidate = useCallback(() => { + if (!assistantId) return; + void queryClient.invalidateQueries({ + queryKey: avatarQueryKey(assistantId), + }); + }, [assistantId, queryClient]); + + return { + components: data?.components ?? null, + traits: data?.traits ?? null, + customImageUrl: data?.customImageUrl ?? null, + isLoading, + invalidate, + }; +} diff --git a/apps/web/src/domains/home/api.ts b/apps/web/src/domains/home/api.ts new file mode 100644 index 00000000000..48a20fd34b8 --- /dev/null +++ b/apps/web/src/domains/home/api.ts @@ -0,0 +1,77 @@ +/** + * Hand-written fetch wrappers for daemon home endpoints. + * + * These endpoints are not in the Django OpenAPI schema, so we use the + * HeyAPI client singleton directly rather than generated hooks. + */ +import { client } from "@/lib/api-client.js"; +import type { + FeedItem, + FeedItemStatus, + HomeFeedResponse, + RelationshipState, +} from "./types.js"; + +export async function fetchHomeFeed( + assistantId: string, + timeAwaySeconds: number = 0, +): Promise { + const { data, response } = await client.get({ + url: "/v1/assistants/{assistant_id}/home/feed", + path: { assistant_id: assistantId }, + query: { timeAwaySeconds }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch home feed: ${response.status}`); + } + return data as HomeFeedResponse; +} + +export async function fetchRelationshipState( + assistantId: string, +): Promise { + const { data, response } = await client.get({ + url: "/v1/assistants/{assistant_id}/home/state", + path: { assistant_id: assistantId }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch relationship state: ${response.status}`); + } + return data as RelationshipState; +} + +export async function updateFeedItemStatus( + assistantId: string, + itemId: string, + status: FeedItemStatus, +): Promise { + const { data, response } = await client.patch({ + url: "/v1/assistants/{assistant_id}/home/feed/{item_id}", + path: { assistant_id: assistantId, item_id: itemId }, + body: { status }, + }); + if (!response.ok) { + throw new Error(`Failed to update feed item: ${response.status}`); + } + return data as FeedItem; +} + +export async function triggerFeedAction( + assistantId: string, + itemId: string, + actionId: string, +): Promise<{ conversationId: string }> { + const { data, response } = await client.post({ + url: "/v1/assistants/{assistant_id}/home/feed/{item_id}/actions/{action_id}", + path: { + assistant_id: assistantId, + item_id: itemId, + action_id: actionId, + }, + body: {}, + }); + if (!response.ok) { + throw new Error(`Failed to trigger feed action: ${response.status}`); + } + return data as { conversationId: string }; +} diff --git a/apps/web/src/domains/home/detail-panel/home-detail-panel.tsx b/apps/web/src/domains/home/detail-panel/home-detail-panel.tsx new file mode 100644 index 00000000000..30605a98a8e --- /dev/null +++ b/apps/web/src/domains/home/detail-panel/home-detail-panel.tsx @@ -0,0 +1,104 @@ +import { Circle, CircleCheck, X } from "lucide-react"; + +import { Button } from "@vellum/design-library/components/button"; +import { Typography } from "@vellum/design-library/components/typography"; +import { CATEGORY_STYLES } from "../home-feed-filter-bar.js"; +import { HomeGenericDetail } from "./home-generic-detail.js"; +import { HomeToolPermissionCard } from "./home-tool-permission-card.js"; +import type { FeedItem, FeedItemCategory, FeedItemStatus } from "../types.js"; + +function resolveCategoryStyle(category?: FeedItemCategory) { + if (category && CATEGORY_STYLES[category]) { + return CATEGORY_STYLES[category]; + } + return CATEGORY_STYLES.system; +} + +export interface HomeDetailPanelProps { + item: FeedItem | null; + onClose: () => void; + onGoToThread: (conversationId: string) => void; + onUpdateStatus: (itemId: string, status: FeedItemStatus) => void; +} + +export function HomeDetailPanel({ + item, + onClose, + onGoToThread, + onUpdateStatus, +}: HomeDetailPanelProps) { + if (!item) return null; + + const panelKind = item.detailPanel?.kind; + const categoryStyle = resolveCategoryStyle(item.category); + const CategoryIcon = categoryStyle.icon; + const isUnread = item.status === "new"; + + return ( +
+ {/* Header */} +
+ + + + {item.title} + + + + ) : null} + +
+ + {/* Scrollable content */} +
+ {panelKind === "toolPermission" ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/src/domains/home/detail-panel/home-generic-detail.tsx b/apps/web/src/domains/home/detail-panel/home-generic-detail.tsx new file mode 100644 index 00000000000..828f57b7ffb --- /dev/null +++ b/apps/web/src/domains/home/detail-panel/home-generic-detail.tsx @@ -0,0 +1,43 @@ +import { Typography } from "@vellum/design-library/components/typography"; +import { CATEGORY_STYLES, CATEGORY_ORDER } from "../home-feed-filter-bar.js"; +import type { FeedItem, FeedItemCategory } from "../types.js"; + +function resolveStyle(category?: FeedItemCategory) { + if (category && CATEGORY_STYLES[category]) { + return CATEGORY_STYLES[category]; + } + const fallback = CATEGORY_ORDER[0] ?? "security"; + return CATEGORY_STYLES[fallback]; +} + +export interface HomeGenericDetailProps { + item: FeedItem; +} + +export function HomeGenericDetail({ item }: HomeGenericDetailProps) { + const style = resolveStyle(item.category); + const Icon = style.icon; + + return ( +
+ + + + {item.summary} + +
+ ); +} diff --git a/apps/web/src/domains/home/detail-panel/home-tool-permission-card.tsx b/apps/web/src/domains/home/detail-panel/home-tool-permission-card.tsx new file mode 100644 index 00000000000..306aadb5775 --- /dev/null +++ b/apps/web/src/domains/home/detail-panel/home-tool-permission-card.tsx @@ -0,0 +1,128 @@ +import { Typography } from "@vellum/design-library/components/typography"; +import type { FeedItem } from "../types.js"; + +type CredentialStatus = + | "revoked" + | "expired" + | "missing_scopes" + | "missing_token" + | "ping_failed" + | "unreachable"; + +function statusDotColor(status: string): string { + switch (status as CredentialStatus) { + case "revoked": + case "expired": + return "var(--system-negative-strong)"; + case "missing_scopes": + case "missing_token": + case "ping_failed": + return "var(--system-mid-strong)"; + case "unreachable": + default: + return "var(--content-disabled)"; + } +} + +function capitalizeStatus(status: string): string { + return status + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +export interface HomeToolPermissionCardProps { + item: FeedItem; +} + +export function HomeToolPermissionCard({ + item, +}: HomeToolPermissionCardProps) { + const metadata = item.metadata; + const provider = metadata?.provider as string | undefined; + + if (!provider) { + return ( + + {item.title} + + ); + } + + const accountInfo = (metadata?.accountInfo as string) ?? null; + const status = (metadata?.status as string) ?? "unreachable"; + const details = (metadata?.details as string) ?? ""; + const missingScopes = Array.isArray(metadata?.missingScopes) + ? (metadata.missingScopes as string[]) + : []; + + return ( +
+ + {provider} + + + {accountInfo ? ( + + {accountInfo} + + ) : null} + +
+
+ + {details ? ( + + {details} + + ) : null} + + {missingScopes.length > 0 ? ( +
+ + Missing scopes + +
    + {missingScopes.map((scope) => ( +
  • + + {scope} + +
  • + ))} +
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/domains/home/home-feed-filter-bar.tsx b/apps/web/src/domains/home/home-feed-filter-bar.tsx new file mode 100644 index 00000000000..5f6dfbbeffa --- /dev/null +++ b/apps/web/src/domains/home/home-feed-filter-bar.tsx @@ -0,0 +1,151 @@ +import { + Bell, + Clock, + List, + Mail, + Settings, + ShieldCheck, +} from "lucide-react"; +import { type ComponentType, type SVGProps } from "react"; + +import { Typography } from "@vellum/design-library/components/typography"; +import { cn } from "@vellum/design-library/utils/cn"; +import type { FeedItemCategory } from "./types.js"; + +type LucideIcon = ComponentType>; + +interface CategoryStyle { + icon: LucideIcon; + strong: string; + weak: string; +} + +export const CATEGORY_STYLES: Record = { + security: { + icon: ShieldCheck, + strong: "var(--feed-nudge-strong)", + weak: "var(--feed-nudge-weak)", + }, + email: { + icon: Mail, + strong: "var(--feed-digest-strong)", + weak: "var(--feed-digest-weak)", + }, + scheduling: { + icon: Clock, + strong: "var(--feed-thread-strong)", + weak: "var(--feed-thread-weak)", + }, + background: { + icon: Settings, + strong: "var(--system-info-strong)", + weak: "var(--system-info-weak)", + }, + system: { + icon: Bell, + strong: "var(--feed-digest-strong)", + weak: "var(--feed-digest-weak)", + }, +}; + +export const CATEGORY_ORDER: FeedItemCategory[] = [ + "security", + "email", + "scheduling", + "background", + "system", +]; + +function FilterPill({ + icon: Icon, + iconColor, + bgColor, + isSelected, + label, + onClick, +}: { + icon: LucideIcon; + iconColor: string; + bgColor: string; + isSelected: boolean; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +export interface HomeFeedFilterBarProps { + categories: FeedItemCategory[]; + activeFilter: FeedItemCategory | null; + onFilterChange: (category: FeedItemCategory | null) => void; +} + +export function HomeFeedFilterBar({ + categories, + activeFilter, + onFilterChange, +}: HomeFeedFilterBarProps) { + const presentCategories = CATEGORY_ORDER.filter((c) => + categories.includes(c), + ); + + if (presentCategories.length === 0) return null; + + return ( +
+ + Filter: + + + onFilterChange(null)} + /> + + {presentCategories.map((category) => { + const style = CATEGORY_STYLES[category]; + return ( + onFilterChange(category)} + /> + ); + })} +
+ ); +} diff --git a/apps/web/src/domains/home/home-feed-list.tsx b/apps/web/src/domains/home/home-feed-list.tsx new file mode 100644 index 00000000000..26c883371d9 --- /dev/null +++ b/apps/web/src/domains/home/home-feed-list.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; + +import { Typography } from "@vellum/design-library/components/typography"; +import { HomeFeedFilterBar } from "./home-feed-filter-bar.js"; +import { HomeRecapRow } from "./home-recap-row.js"; +import { + excludeHighUrgency, + filterByCategory, + getPresentCategories, + groupByTime, + sortFeedItems, +} from "./utils/feed-utils.js"; +import type { FeedItem, FeedItemCategory, FeedTimeGroup } from "./types.js"; + +const TIME_GROUP_LABELS: Record = { + today: "Today", + yesterday: "Yesterday", + older: "Older", +}; + +export interface HomeFeedListProps { + items: FeedItem[]; + onSelectItem: (item: FeedItem) => void; + onDismissItem: (itemId: string) => void; +} + +export function HomeFeedList({ + items, + onSelectItem, + onDismissItem, +}: HomeFeedListProps) { + const [activeFilter, setActiveFilter] = useState( + null, + ); + + const visible = items.filter((item) => item.status !== "dismissed"); + const eligible = excludeHighUrgency(visible); + const presentCategories = getPresentCategories(eligible); + const filtered = filterByCategory(eligible, activeFilter); + const sorted = sortFeedItems(filtered); + const grouped = groupByTime(sorted); + + return ( +
+ + + {grouped.size === 0 ? ( + + {activeFilter + ? "No items match the selected filter." + : "No items to show."} + + ) : ( + [...grouped.entries()].map(([group, groupItems]) => ( +
+ + {TIME_GROUP_LABELS[group]} + + +
+ {groupItems.map((item) => ( + + ))} +
+
+ )) + )} +
+ ); +} diff --git a/apps/web/src/domains/home/home-greeting-header.tsx b/apps/web/src/domains/home/home-greeting-header.tsx new file mode 100644 index 00000000000..9015b12e3cb --- /dev/null +++ b/apps/web/src/domains/home/home-greeting-header.tsx @@ -0,0 +1,44 @@ +import { SquarePen } from "lucide-react"; + +import { Button } from "@vellum/design-library/components/button"; +import { Typography } from "@vellum/design-library/components/typography"; +import { ChatAvatar } from "@/components/avatar/chat-avatar.js"; +import type { CharacterComponents, CharacterTraits } from "@/domains/avatar/types.js"; + +interface HomeGreetingHeaderProps { + avatarComponents: CharacterComponents | null; + avatarTraits: CharacterTraits | null; + avatarImageUrl: string | null; + onStartNewChat: () => void; +} + +export function HomeGreetingHeader({ + avatarComponents, + avatarTraits, + avatarImageUrl, + onStartNewChat, +}: HomeGreetingHeaderProps) { + return ( +
+
+ + + Here's what's been going on + +
+ + +
+ ); +} diff --git a/apps/web/src/domains/home/home-page.tsx b/apps/web/src/domains/home/home-page.tsx new file mode 100644 index 00000000000..c090ecd694e --- /dev/null +++ b/apps/web/src/domains/home/home-page.tsx @@ -0,0 +1,180 @@ +import { useCallback, useState } from "react"; + +import { ResizablePanel } from "@/components/resizable-panel.js"; +import { useAssistantAvatar } from "@/domains/avatar/use-assistant-avatar.js"; +import { useIsMobile } from "@/hooks/use-is-mobile.js"; +import { HomeDetailPanel } from "./detail-panel/home-detail-panel.js"; +import { HomeFeedList } from "./home-feed-list.js"; +import { HomeGreetingHeader } from "./home-greeting-header.js"; +import { HomeSuggestionPillBar } from "./home-suggestion-pill-bar.js"; +import { useHomeFeedQuery } from "./hooks/use-home-feed-query.js"; +import { useHomeStateQuery } from "./hooks/use-home-state-query.js"; +import type { FeedItem, FeedItemStatus, SuggestedPrompt } from "./types.js"; + +function HomePageSkeleton() { + return ( +
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+ {Array.from({ length: 4 }, (_, i) => ( +
+ ))} +
+
+ ); +} + +export interface HomePageProps { + assistantId: string; + onStartNewChat: () => void; + onOpenConversation: (conversationId: string) => void; + onSuggestionSelected: (prompt: string) => void; +} + +export function HomePage({ + assistantId, + onStartNewChat, + onOpenConversation, + onSuggestionSelected, +}: HomePageProps) { + const isMobile = useIsMobile(); + const avatar = useAssistantAvatar(assistantId); + const feedQuery = useHomeFeedQuery(assistantId); + useHomeStateQuery(assistantId); + + const [selectedItem, setSelectedItem] = useState(null); + + const handleSelectItem = useCallback( + (item: FeedItem) => { + if (item.status === "new") { + setSelectedItem({ ...item, status: "seen" }); + feedQuery.updateStatus.mutate({ itemId: item.id, status: "seen" }); + } else { + setSelectedItem(item); + } + }, + [feedQuery.updateStatus], + ); + + const handleCloseDetail = useCallback(() => { + setSelectedItem(null); + }, []); + + const handleDismissItem = useCallback( + (itemId: string) => { + feedQuery.updateStatus.mutate({ itemId, status: "dismissed" }); + if (selectedItem?.id === itemId) { + setSelectedItem(null); + } + }, + [feedQuery.updateStatus, selectedItem?.id], + ); + + const handleUpdateStatus = useCallback( + (itemId: string, status: FeedItemStatus) => { + feedQuery.updateStatus.mutate({ itemId, status }); + setSelectedItem((prev) => + prev?.id === itemId ? { ...prev, status } : prev, + ); + }, + [feedQuery.updateStatus], + ); + + const handleGoToThread = useCallback( + (conversationId: string) => { + setSelectedItem(null); + onOpenConversation(conversationId); + }, + [onOpenConversation], + ); + + const handleSuggestionSelect = useCallback( + (prompt: SuggestedPrompt) => { + onSuggestionSelected(prompt.prompt); + }, + [onSuggestionSelected], + ); + + const feedContent = feedQuery.isLoading ? ( + + ) : ( + <> + + + + + ); + + if (selectedItem && isMobile) { + return ( +
+ +
+ ); + } + + if (selectedItem && !isMobile) { + return ( + + {feedContent} +
+ } + right={ + + } + /> + ); + } + + return ( +
+
+ {feedContent} +
+
+ ); +} diff --git a/apps/web/src/domains/home/home-recap-row.tsx b/apps/web/src/domains/home/home-recap-row.tsx new file mode 100644 index 00000000000..8c184eb27e1 --- /dev/null +++ b/apps/web/src/domains/home/home-recap-row.tsx @@ -0,0 +1,95 @@ +import { X } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "@vellum/design-library/utils/cn"; +import { CATEGORY_STYLES } from "./home-feed-filter-bar.js"; +import type { FeedItem, FeedItemCategory } from "./types.js"; + +function resolveStyle(category?: FeedItemCategory) { + if (category && CATEGORY_STYLES[category]) { + return CATEGORY_STYLES[category]; + } + return CATEGORY_STYLES.system; +} + +export interface HomeRecapRowProps { + item: FeedItem; + onSelect: (item: FeedItem) => void; + onDismiss: (itemId: string) => void; +} + +export function HomeRecapRow({ item, onSelect, onDismiss }: HomeRecapRowProps) { + const [isHovering, setIsHovering] = useState(false); + const style = resolveStyle(item.category); + const Icon = style.icon; + const isUnread = item.status === "new"; + + return ( + + ); +} diff --git a/apps/web/src/domains/home/home-suggestion-pill-bar.tsx b/apps/web/src/domains/home/home-suggestion-pill-bar.tsx new file mode 100644 index 00000000000..9f4c916f736 --- /dev/null +++ b/apps/web/src/domains/home/home-suggestion-pill-bar.tsx @@ -0,0 +1,94 @@ +import { icons, Sparkles, X } from "lucide-react"; +import { useState } from "react"; + +import { Typography } from "@vellum/design-library/components/typography"; +import type { SuggestedPrompt } from "./types.js"; + +function toPascalCase(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * Resolves a daemon icon key (bare Lucide camelCase like "mail", "fileText") + * to a lucide-react component. Matches the macOS resolveIcon(_:) algorithm: + * try direct PascalCase lookup, then strip "lucide-" prefix and retry. + */ +function resolveIcon(iconName: string | undefined) { + if (!iconName) return Sparkles; + + const pascal = toPascalCase(iconName); + if (icons[pascal as keyof typeof icons]) { + return icons[pascal as keyof typeof icons]; + } + + const stripped = iconName.replace(/^lucide-/, ""); + if (stripped !== iconName) { + const strippedPascal = toPascalCase(stripped); + if (icons[strippedPascal as keyof typeof icons]) { + return icons[strippedPascal as keyof typeof icons]; + } + } + + return Sparkles; +} + +interface HomeSuggestionPillBarProps { + suggestions: SuggestedPrompt[]; + onSelect: (prompt: SuggestedPrompt) => void; +} + +export function HomeSuggestionPillBar({ + suggestions, + onSelect, +}: HomeSuggestionPillBarProps) { + const [dismissed, setDismissed] = useState(false); + + if (dismissed || suggestions.length === 0) return null; + + const visible = suggestions.slice(0, 3); + + return ( +
+
+ + By the way, have you tried one of these: + + +
+
+ {visible.map((suggestion) => { + const Icon = resolveIcon(suggestion.icon); + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/web/src/domains/home/hooks/use-home-feed-query.ts b/apps/web/src/domains/home/hooks/use-home-feed-query.ts new file mode 100644 index 00000000000..11c50653cd7 --- /dev/null +++ b/apps/web/src/domains/home/hooks/use-home-feed-query.ts @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + fetchHomeFeed, + triggerFeedAction, + updateFeedItemStatus, +} from "../api.js"; +import type { + FeedItem, + FeedItemStatus, + HomeFeedResponse, +} from "../types.js"; + +const QUERY_KEY_PREFIX = "home-feed" as const; + +function homeFeedQueryKey(assistantId: string) { + return [QUERY_KEY_PREFIX, assistantId] as const; +} + +/** + * React Query hook for the home feed. + * + * Tracks time-away via `document.visibilitychange` so the daemon can + * personalise the greeting and decide which items to surface. + */ +export function useHomeFeedQuery(assistantId: string | null) { + const queryClient = useQueryClient(); + + const hiddenAtRef = useRef(null); + const timeAwaySecondsRef = useRef(0); + + useEffect(() => { + function handleVisibilityChange() { + if (document.hidden) { + hiddenAtRef.current = Date.now(); + } else if (hiddenAtRef.current !== null) { + const elapsed = Math.round( + (Date.now() - hiddenAtRef.current) / 1000, + ); + timeAwaySecondsRef.current = elapsed; + hiddenAtRef.current = null; + + if (assistantId) { + void queryClient.invalidateQueries({ + queryKey: homeFeedQueryKey(assistantId), + }); + } + } + } + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [assistantId, queryClient]); + + const query = useQuery({ + queryKey: homeFeedQueryKey(assistantId ?? ""), + queryFn: () => + fetchHomeFeed(assistantId!, timeAwaySecondsRef.current), + enabled: Boolean(assistantId), + staleTime: 30_000, + }); + + const updateStatus = useMutation({ + mutationFn: ({ + itemId, + status, + }: { + itemId: string; + status: FeedItemStatus; + }) => updateFeedItemStatus(assistantId!, itemId, status), + + onMutate: async ({ itemId, status }) => { + const key = homeFeedQueryKey(assistantId!); + await queryClient.cancelQueries({ queryKey: key }); + + const previous = queryClient.getQueryData(key); + + queryClient.setQueryData(key, (old) => { + if (!old) return old; + return { + ...old, + items: + status === "dismissed" + ? old.items.filter((item: FeedItem) => item.id !== itemId) + : old.items.map((item: FeedItem) => + item.id === itemId ? { ...item, status } : item, + ), + }; + }); + + return { previous }; + }, + + onError: (_err, _vars, context) => { + if (context?.previous && assistantId) { + queryClient.setQueryData( + homeFeedQueryKey(assistantId), + context.previous, + ); + } + }, + + onSettled: () => { + if (assistantId) { + void queryClient.invalidateQueries({ + queryKey: homeFeedQueryKey(assistantId), + }); + } + }, + }); + + const triggerAction = useMutation({ + mutationFn: ({ + itemId, + actionId, + }: { + itemId: string; + actionId: string; + }) => triggerFeedAction(assistantId!, itemId, actionId), + + onMutate: async ({ itemId }) => { + const key = homeFeedQueryKey(assistantId!); + await queryClient.cancelQueries({ queryKey: key }); + + const previous = queryClient.getQueryData(key); + + queryClient.setQueryData(key, (old) => { + if (!old) return old; + return { + ...old, + items: old.items.map((item: FeedItem) => + item.id === itemId + ? { ...item, status: "acted_on" as const } + : item, + ), + }; + }); + + return { previous }; + }, + + onError: (_err, _vars, context) => { + if (context?.previous && assistantId) { + queryClient.setQueryData( + homeFeedQueryKey(assistantId), + context.previous, + ); + } + }, + + onSettled: () => { + if (assistantId) { + void queryClient.invalidateQueries({ + queryKey: homeFeedQueryKey(assistantId), + }); + } + }, + }); + + const invalidate = useCallback(() => { + if (!assistantId) return; + void queryClient.invalidateQueries({ + queryKey: homeFeedQueryKey(assistantId), + }); + }, [assistantId, queryClient]); + + return { + ...query, + updateStatus, + triggerAction, + invalidate, + }; +} diff --git a/apps/web/src/domains/home/hooks/use-home-state-query.ts b/apps/web/src/domains/home/hooks/use-home-state-query.ts new file mode 100644 index 00000000000..b2ce4dc0270 --- /dev/null +++ b/apps/web/src/domains/home/hooks/use-home-state-query.ts @@ -0,0 +1,38 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { fetchRelationshipState } from "../api.js"; +import type { RelationshipState } from "../types.js"; + +const QUERY_KEY_PREFIX = "home-state" as const; + +function homeStateQueryKey(assistantId: string) { + return [QUERY_KEY_PREFIX, assistantId] as const; +} + +/** + * React Query hook for the assistant relationship state (tier, facts, + * capabilities, conversation count, etc.). + */ +export function useHomeStateQuery(assistantId: string | null) { + const queryClient = useQueryClient(); + + const query = useQuery({ + queryKey: homeStateQueryKey(assistantId ?? ""), + queryFn: () => fetchRelationshipState(assistantId!), + enabled: Boolean(assistantId), + staleTime: 60_000, + }); + + const invalidate = useCallback(() => { + if (!assistantId) return; + void queryClient.invalidateQueries({ + queryKey: homeStateQueryKey(assistantId), + }); + }, [assistantId, queryClient]); + + return { + ...query, + invalidate, + }; +} diff --git a/apps/web/src/domains/home/types.ts b/apps/web/src/domains/home/types.ts new file mode 100644 index 00000000000..c6630eb3deb --- /dev/null +++ b/apps/web/src/domains/home/types.ts @@ -0,0 +1,108 @@ +export type FeedItemType = "notification"; +export type FeedItemStatus = "new" | "seen" | "acted_on" | "dismissed"; +export type FeedItemUrgency = "low" | "medium" | "high" | "critical"; +export type FeedItemCategory = + | "security" + | "scheduling" + | "background" + | "email" + | "system"; + +export type FeedItemDetailPanelKind = + | "emailDraft" + | "documentPreview" + | "permissionChat" + | "paymentAuth" + | "toolPermission" + | "updatesList"; + +export interface FeedAction { + id: string; + label: string; + prompt: string; +} + +export interface FeedItemDetailPanel { + kind: FeedItemDetailPanelKind; +} + +export interface FeedItem { + id: string; + type: FeedItemType; + priority: number; + title: string; + summary: string; + timestamp: string; + status: FeedItemStatus; + expiresAt?: string; + actions?: FeedAction[]; + urgency?: FeedItemUrgency; + conversationId?: string; + detailPanel?: FeedItemDetailPanel; + category?: FeedItemCategory; + metadata?: Record; + createdAt: string; +} + +export type SuggestedPromptSource = "deterministic" | "assistant"; +export interface SuggestedPrompt { + id: string; + label: string; + icon?: string; + prompt: string; + source: SuggestedPromptSource; +} + +export interface ContextBanner { + greeting: string; + timeAwayLabel: string; + newCount: number; +} + +export interface HomeFeedResponse { + items: FeedItem[]; + updatedAt: string; + contextBanner: ContextBanner; + suggestedPrompts: SuggestedPrompt[]; +} + +export type RelationshipTier = 1 | 2 | 3 | 4; +export type FactCategory = "voice" | "world" | "priorities"; +export type FactConfidence = "strong" | "uncertain"; +export type FactSource = "onboarding" | "inferred"; + +export interface Fact { + id: string; + category: FactCategory; + text: string; + confidence: FactConfidence; + source: FactSource; +} + +export type CapabilityTier = "unlocked" | "next-up" | "earned"; + +export interface Capability { + id: string; + name: string; + description: string; + tier: CapabilityTier; + gate: string; + unlockHint?: string; + ctaLabel?: string; +} + +export interface RelationshipState { + version: number; + assistantId: string; + tier: RelationshipTier; + progressPercent: number; + facts: Fact[]; + capabilities: Capability[]; + conversationCount: number; + hatchedDate: string; + assistantName: string; + userName?: string; + updatedAt: string; +} + +export type FeedTimeGroup = "today" | "yesterday" | "older"; diff --git a/apps/web/src/domains/home/utils/feed-utils.ts b/apps/web/src/domains/home/utils/feed-utils.ts new file mode 100644 index 00000000000..c9b4f8dd5b4 --- /dev/null +++ b/apps/web/src/domains/home/utils/feed-utils.ts @@ -0,0 +1,91 @@ +import type { + FeedItem, + FeedItemCategory, + FeedTimeGroup, +} from "../types.js"; + +/** + * Sort feed items by priority descending, then by createdAt descending. + */ +export function sortFeedItems(items: FeedItem[]): FeedItem[] { + return [...items].sort((a, b) => { + if (a.priority !== b.priority) return b.priority - a.priority; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); +} + +/** + * Bucket items into "today", "yesterday", or "older" based on createdAt + * in the local timezone. Returns a Map preserving order. Empty groups + * are omitted. + */ +export function groupByTime( + items: FeedItem[], +): Map { + const now = new Date(); + const todayStart = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + const yesterdayStart = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate() - 1, + ); + + const groups: Record = { + today: [], + yesterday: [], + older: [], + }; + + for (const item of items) { + const created = new Date(item.createdAt); + if (created >= todayStart) { + groups.today.push(item); + } else if (created >= yesterdayStart) { + groups.yesterday.push(item); + } else { + groups.older.push(item); + } + } + + const result = new Map(); + if (groups.today.length > 0) result.set("today", groups.today); + if (groups.yesterday.length > 0) result.set("yesterday", groups.yesterday); + if (groups.older.length > 0) result.set("older", groups.older); + + return result; +} + +/** + * Filter items by category. If category is null, return all items. + */ +export function filterByCategory( + items: FeedItem[], + category: FeedItemCategory | null, +): FeedItem[] { + if (category === null) return items; + return items.filter((item) => (item.category ?? "system") === category); +} + +/** + * Exclude items with urgency "high" or "critical". + */ +export function excludeHighUrgency(items: FeedItem[]): FeedItem[] { + return items.filter( + (item) => item.urgency !== "high" && item.urgency !== "critical", + ); +} + +/** + * Return deduplicated list of categories present in the items. + */ +export function getPresentCategories(items: FeedItem[]): FeedItemCategory[] { + const categories = new Set(); + for (const item of items) { + categories.add(item.category ?? "system"); + } + return [...categories]; +} diff --git a/apps/web/src/hooks/use-is-mobile.ts b/apps/web/src/hooks/use-is-mobile.ts new file mode 100644 index 00000000000..c0c1ee97758 --- /dev/null +++ b/apps/web/src/hooks/use-is-mobile.ts @@ -0,0 +1,26 @@ +import { useSyncExternalStore } from "react"; + +/** + * Media query that marks viewports narrow enough to swap a sidebar rail + * for an overlay drawer. Mirrors `SidebarPageLayout`'s `md:` breakpoint + * (768px). + */ +export const MOBILE_MEDIA_QUERY = "(max-width: 767px)"; + +function subscribe(onChange: () => void): () => void { + const mql = window.matchMedia(MOBILE_MEDIA_QUERY); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); +} + +function getSnapshot(): boolean { + return window.matchMedia(MOBILE_MEDIA_QUERY).matches; +} + +/** + * Returns `true` while the viewport matches `MOBILE_MEDIA_QUERY` + * (`max-width: 767px`). + */ +export function useIsMobile(): boolean { + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts new file mode 100644 index 00000000000..c1781bf74e2 --- /dev/null +++ b/apps/web/src/lib/api-client.ts @@ -0,0 +1,15 @@ +/** + * Configured HeyAPI client for daemon API requests. + * + * All hand-written API wrappers (home feed, avatar, etc.) import this + * singleton instead of depending on generated code. The generated + * client (from codegen) uses its own inline-bundled instance; this one + * is for endpoints that aren't in the OpenAPI spec. + * + * Reference: https://heyapi.dev/openapi-ts/clients/fetch + */ +import { createClient } from "@hey-api/client-fetch"; + +export const client = createClient({ + baseUrl: "", +}); diff --git a/apps/web/src/lib/api-errors.ts b/apps/web/src/lib/api-errors.ts new file mode 100644 index 00000000000..6060b8ff46b --- /dev/null +++ b/apps/web/src/lib/api-errors.ts @@ -0,0 +1,17 @@ +/** + * Assert that a Response object is present. + * + * HeyAPI client calls return `{ data, error, response }` where `response` + * can be `undefined` when the request never reached the server (e.g. network + * error). This helper narrows the type and throws a descriptive error when + * it is missing. + */ +export function assertHasResponse( + response: Response | undefined, + error: unknown, + fallbackMessage: string, +): asserts response is Response { + if (response) return; + if (error instanceof Error) throw error; + throw new Error(fallbackMessage); +} diff --git a/apps/web/src/lib/query-client.ts b/apps/web/src/lib/query-client.ts new file mode 100644 index 00000000000..6bc5929d29a --- /dev/null +++ b/apps/web/src/lib/query-client.ts @@ -0,0 +1,10 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9b9b9bedb0f..32ca78393fc 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,6 +1,8 @@ +import { QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { RouterProvider } from "react-router"; +import { queryClient } from "./lib/query-client.js"; import { router } from "./routes.js"; import "./index.css"; @@ -9,6 +11,8 @@ if (!rootEl) throw new Error("Root element #root not found"); createRoot(rootEl).render( - + + + ); diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index b97953c02d4..3c5582499bb 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -1,6 +1,7 @@ import { createBrowserRouter } from "react-router"; import { App } from "./App.js"; import { ChatPage } from "./domains/chat/chat-page.js"; +import { HomePage } from "./domains/home/home-page.js"; import { LibraryPage } from "./domains/library/library-page.js"; import { LibraryDetailPage } from "./domains/library/library-detail-page.js"; import { NotFound } from "./components/not-found.js"; @@ -13,6 +14,23 @@ export const router = createBrowserRouter( element: , children: [ { index: true, element: }, + { + path: "home", + element: ( + { + window.location.href = "/assistant"; + }} + onOpenConversation={(conversationId) => { + window.location.href = `/assistant/conversations/${conversationId}`; + }} + onSuggestionSelected={(prompt) => { + window.location.href = `/assistant?prompt=${encodeURIComponent(prompt)}`; + }} + /> + ), + }, { path: "settings/:tab", element: }, { path: "library", element: }, { path: "library/:appId", element: }, diff --git a/packages/design-library/src/components/button.tsx b/packages/design-library/src/components/button.tsx index 1ab1ebbcebb..6ba4ede1151 100644 --- a/packages/design-library/src/components/button.tsx +++ b/packages/design-library/src/components/button.tsx @@ -2,40 +2,103 @@ import { type ComponentProps, type ReactNode } from "react"; import { cn } from "../utils/cn.js"; +export type ButtonVariant = "primary" | "secondary" | "ghost" | "outlined"; +export type ButtonSize = "sm" | "md" | "lg" | "compact"; + export interface ButtonProps extends ComponentProps<"button"> { - variant?: "primary" | "secondary" | "ghost"; - size?: "sm" | "md" | "lg"; - children: ReactNode; + variant?: ButtonVariant; + size?: ButtonSize; + /** Icon rendered before the text label. */ + leftIcon?: ReactNode; + /** Icon rendered after the text label. */ + rightIcon?: ReactNode; + /** + * Render as an icon-only button. The node is centered and `children`, + * `leftIcon`, and `rightIcon` are ignored. + */ + iconOnly?: ReactNode; + children?: ReactNode; } -const VARIANT_CLASSES: Record, string> = { - primary: "vdl-btn-primary", - secondary: "vdl-btn-secondary", - ghost: "vdl-btn-ghost", +const VARIANT_CLASSES: Record = { + primary: + "bg-[var(--primary-active)] text-white hover:bg-[var(--primary-hover)]", + secondary: + "bg-[var(--surface-lift)] text-[var(--content-default)] hover:bg-[var(--surface-active)]", + ghost: + "bg-transparent text-[var(--content-secondary)] hover:bg-[var(--surface-lift)] hover:text-[var(--content-default)]", + outlined: + "border border-[var(--border-base)] bg-transparent text-[var(--content-secondary)] hover:bg-[var(--surface-base)] hover:text-[var(--content-default)]", +}; + +const SIZE_CLASSES: Record = { + sm: "h-7 gap-1.5 rounded-md px-2.5 text-body-small-default", + md: "h-9 gap-2 rounded-lg px-3.5 text-body-medium-default", + lg: "h-11 gap-2.5 rounded-lg px-5 text-body-large-default", + compact: "h-6 gap-1.5 rounded-md px-2 text-label-medium-default", }; -const SIZE_CLASSES: Record, string> = { - sm: "vdl-btn-sm", - md: "vdl-btn-md", - lg: "vdl-btn-lg", +const ICON_ONLY_SIZE: Record = { + sm: "size-7", + md: "size-9", + lg: "size-11", + compact: "size-6", +}; + +const ICON_SIZE: Record = { + sm: "[&_svg]:size-3.5", + md: "[&_svg]:size-4", + lg: "[&_svg]:size-5", + compact: "[&_svg]:size-3.5", }; export function Button({ variant = "primary", size = "md", + leftIcon, + rightIcon, + iconOnly, className, children, ref, ...props }: ButtonProps) { + const isIconOnly = iconOnly != null && iconOnly !== false; + return ( ); }