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: 4 additions & 1 deletion packages/ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-avatar-mono: var(--avatar-mono);
--color-avatar-red: var(--avatar-red);
--color-avatar-orange: var(--avatar-orange);
--color-avatar-yellow: var(--avatar-yellow);
Expand Down Expand Up @@ -128,7 +129,8 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* A persona's avatar colors (@proxy/utils/harness AVATAR_COLORS), one hue each. */
/* A persona's avatar colors (@proxy/utils/harness AVATAR_COLORS), one hue each, and mono, the page's ink. */
--avatar-mono: oklch(0.21 0 0);
--avatar-red: oklch(0.64 0.21 25);
--avatar-orange: oklch(0.72 0.17 55);
--avatar-yellow: oklch(0.8 0.16 90);
Expand Down Expand Up @@ -171,6 +173,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--avatar-mono: oklch(0.985 0 0);
--avatar-red: oklch(0.7 0.19 25);
--avatar-orange: oklch(0.76 0.16 55);
--avatar-yellow: oklch(0.84 0.15 90);
Expand Down
4 changes: 2 additions & 2 deletions packages/utils/src/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,8 @@ describe('sleepUntil', () => {
});

describe('heads', () => {
test('are one of eight colors, told from anything else', () => {
expect(AVATAR_COLORS).toHaveLength(8);
test('are one of nine colors, told from anything else', () => {
expect(AVATAR_COLORS).toHaveLength(9);
expect(isAvatarColor('blue')).toBe(true);
expect(isAvatarColor('brown')).toBe(false);
});
Expand Down
12 changes: 7 additions & 5 deletions packages/utils/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,14 @@ export function threadState(
}

/**
* A built-in agent's head, as the head lab (design/head-lab) makes it: a sphere in one of eight
* colors, with painted eyes and a darker nose sphere. A look is what the person keeps beside its
* name: the shape of its features, in the lab's units; the color is kept beside it. How it is
* turned is not part of a look: the app poses a head as the moment asks.
* A built-in agent's head: a ball of fur in one of nine colors, with two ink pills for eyes. A look
* is what the person keeps beside its name: the shape of its features, in the head lab's units,
* from which the web app seeds its fur; the color is kept beside it. How it is turned is not part
* of a look: the app poses a head as the moment asks.
*/
export const AVATAR_COLORS = [
// The page's own ink: near-black on a light page, white on a dark one.
'mono',
'red',
'orange',
'yellow',
Expand All @@ -179,7 +181,7 @@ export function isAvatarColor(value: string): value is AvatarColor {
return (AVATAR_COLORS as readonly string[]).includes(value);
}

/** A color as it is kept, one of the eight; blue, the first head's, for one that isn't. */
/** A color as it is kept, one of the nine; blue, the first head's, for one that isn't. */
export function toAvatarColor(value: string): AvatarColor {
if (isAvatarColor(value)) {
return value;
Expand Down
67 changes: 57 additions & 10 deletions packages/web/src/components/head.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import {useCallback, useEffect, useRef} from 'react';
import {useCallback, useEffect, useRef, useSyncExternalStore} from 'react';
import type {Look} from '@proxy/utils/harness';
import {cn} from '@proxy/ui/lib/utils';
import {
BLINK_MS,
blinkAmount,
FACING,
FIRST_HOVER_DURATION_MS,
followAmount,
followTime,
loadStage,
nextBlinkDelay,
paintHead,
pixels,
poseTowardPointer,
type Pose,
REST,
redrawVersion,
subscribeRedraw,
} from '@/lib/head';

// A persona's head, drawn as the head lab draws it (lib/head.ts). It is decorative: the text
// A persona's head, drawn as lib/head.ts draws it. It is decorative: the text
// beside one always names it.

/** A still head, `size` CSS pixels wide, in its pose; at rest unless another is given. */
Expand All @@ -32,8 +37,9 @@ export function Head({
className?: string;
}) {
const ref = useRef<HTMLCanvasElement>(null);
// Drawn again only when the head itself changes, by value: the page reads its agents again as
// it goes, and gets new objects of the same heads each time.
const redraws = useSyncExternalStore(subscribeRedraw, redrawVersion);
// Drawn again only when the head itself changes, by value, or the page's theme does:
// the page reads its agents again as it goes, and gets new objects of the same heads each time.
const key = JSON.stringify([look, color, pose]);
const latest = useRef({look, color, pose});
useEffect(() => {
Expand All @@ -58,7 +64,7 @@ export function Head({
return () => {
current = false;
};
}, [key, size]);
}, [key, size, redraws]);
return (
<canvas
ref={ref}
Expand Down Expand Up @@ -105,9 +111,10 @@ function toward(canvas: HTMLCanvasElement, tracking: 'head' | 'viewport'): Pose

/**
* A head that looks at the cursor while `watching`: facing you, turning toward the pointer
* wherever it goes on the page; once it stops watching, it settles back to rest. Either way it is
* drawn only while it has somewhere to go: once it is where it should look, it stays, drawn no
* more until the pointer moves, or it is to look elsewhere.
* wherever it goes on the page, and blinking every few seconds; once it stops watching, it settles
* back to rest. Clicked, it blinks, watching or not. Either way it is drawn only while it has
* somewhere to go or is blinking: once it is where it should look, it stays, drawn no more until
* the pointer moves, it is to look elsewhere, or it blinks.
*/
export function LiveHead({
look,
Expand Down Expand Up @@ -137,16 +144,42 @@ export function LiveHead({
const trackingMode = useRef(tracking);
const memory = useRef({recallPose, rememberPose});
const firstHoverUntil = useRef(0);
// When the blink under way started; none between blinks.
const blinkStart = useRef<number | null>(null);
const wake = useRef<(() => void) | null>(null);
const blink = useCallback(() => {
blinkStart.current = performance.now();
wake.current?.();
}, []);
const wakeFromPointer = useCallback(() => {
if (!watch.current) {
return;
}
wake.current?.();
}, []);
const redraws = useSyncExternalStore(subscribeRedraw, redrawVersion);
useEffect(() => {
memory.current = {recallPose, rememberPose};
}, [recallPose, rememberPose]);
// Blinks now and then, while it follows the cursor.
useEffect(() => {
if (!watching) {
return;
}
let timer = 0;
const plan = () => {
timer = window.setTimeout(() => {
blink();
plan();
}, nextBlinkDelay());
};
plan();
return () => window.clearTimeout(timer);
}, [watching, blink]);
// Drawn again with the page's theme as it is now.
useEffect(() => {
wake.current?.();
}, [redraws]);
useEffect(() => {
if (!watch.current && watching) {
firstHoverUntil.current = performance.now() + FIRST_HOVER_DURATION_MS;
Expand Down Expand Up @@ -206,9 +239,22 @@ export function LiveHead({
pose.rotY += (target.rotY - pose.rotY) * amount;
pose.rotZ += (target.rotZ - pose.rotZ) * amount;
}
const blinking = blinkStart.current !== null && now - blinkStart.current < BLINK_MS;
const closed = blinkAmount(now, blinkStart.current);
if (!blinking) {
blinkStart.current = null;
}
memory.current.rememberPose?.({...pose});
paintHead(stage, canvas, spec.current, pose, pixels(size), watch.current || !settled);
if (settled) {
paintHead(
stage,
canvas,
spec.current,
pose,
pixels(size),
watch.current || !settled || blinking,
closed,
);
if (settled && !blinking) {
running = false;
return;
}
Expand Down Expand Up @@ -240,6 +286,7 @@ export function LiveHead({
aria-hidden
data-slot="live-head"
data-state={watching ? 'watching' : 'rest'}
onClick={blink}
className={cn('shrink-0', className)}
style={{width: size, height: size}}
/>
Expand Down
29 changes: 8 additions & 21 deletions packages/web/src/components/persona-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {Persona} from '@proxy/contracts/harness';
import {RotateCcwIcon, ShuffleIcon} from 'lucide-react';
import {ShuffleIcon} from 'lucide-react';
import {useState} from 'react';
import {CHAT_MODELS, type ChatModel} from '@proxy/utils';
import {AGENT_NAME_MAX} from '@proxy/utils/agent-providers';
Expand Down Expand Up @@ -35,6 +35,7 @@ type Props = {

// The colors as the stylesheet has them, for the swatches.
const BACKGROUND: Record<AvatarColor, string> = {
mono: 'bg-avatar-mono',
red: 'bg-avatar-red',
orange: 'bg-avatar-orange',
yellow: 'bg-avatar-yellow',
Expand All @@ -45,7 +46,7 @@ const BACKGROUND: Record<AvatarColor, string> = {
pink: 'bg-avatar-pink',
};

// A new persona starts with a look of its own, and a color drawn from the eight.
// A new persona starts with a look of its own, and a color drawn from the nine.
function fresh(): Persona {
return {
name: '',
Expand Down Expand Up @@ -85,17 +86,16 @@ function ModelChoice({
);
}

// A round button the size of a swatch, for the colors and the two about the look.
// A round button the size of a swatch, for the colors and the one for another look.
const ROUND =
'flex size-9 shrink-0 items-center justify-center rounded-full transition-[box-shadow,background-color] outline-none focus-visible:ring-3 focus-visible:ring-ring/50';

// Making a persona, or changing one: its name, and its head, which looks at the cursor as the
// person works, in one of the colors, with another look a click away and the way back to the one
// it opened with; in an insider organization, the model it answers with too. Mount it keyed by
// what is edited, so the fields start from it.
// person works, in one of the colors, with another look a click away; in an insider
// organization, the model it answers with too. Mount it keyed by what is edited, so the fields
// start from it.
export function PersonaDialog({open, initial, chooseModel, onClose, onSave, onDelete}: Props) {
const [opened] = useState<Persona>(() => initial ?? fresh());
const [persona, setPersona] = useState<Persona>(opened);
const [persona, setPersona] = useState<Persona>(() => initial ?? fresh());
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const ready = persona.name.trim() !== '';
Expand Down Expand Up @@ -195,19 +195,6 @@ export function PersonaDialog({open, initial, chooseModel, onClose, onSave, onDe
>
<ShuffleIcon className="size-4" />
</button>
<button
type="button"
aria-label="The look it opened with"
title="The look it opened with"
disabled={persona.look === opened.look}
onClick={() => setPersona({...persona, look: opened.look})}
className={cn(
ROUND,
'border text-muted-foreground hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40',
)}
>
<RotateCcwIcon className="size-4" />
</button>
</div>
</div>
</div>
Expand Down
20 changes: 20 additions & 0 deletions packages/web/src/lib/head.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import {describe, expect, test} from 'bun:test';
import {
BLINK_MS,
blinkAmount,
FACING,
FIRST_HOVER_FOLLOW_TIME_MS,
FOLLOW_TIME_MS,
followAmount,
followTime,
nextBlinkDelay,
poseTowardPointer,
} from '@/lib/head';

Expand Down Expand Up @@ -72,3 +75,20 @@ describe('followAmount', () => {
expect(followTime(false, 599, 600)).toBe(FOLLOW_TIME_MS);
});
});

describe('blinking', () => {
test('closes the eyes fully halfway through a blink and opens them again', () => {
expect(blinkAmount(1000, null)).toBe(0);
expect(blinkAmount(1000, 1000)).toBe(0);
expect(blinkAmount(1000 + BLINK_MS / 4, 1000)).toBeCloseTo(0.5);
expect(blinkAmount(1000 + BLINK_MS / 2, 1000)).toBe(1);
expect(blinkAmount(1000 + BLINK_MS, 1000)).toBe(0);
expect(blinkAmount(900, 1000)).toBe(0);
});

test('waits between two and six seconds before blinking again', () => {
expect(nextBlinkDelay(() => 0)).toBe(2000);
expect(nextBlinkDelay(() => 0.5)).toBe(4000);
expect(nextBlinkDelay(() => 0.999999)).toBeLessThan(6000);
});
});
Loading
Loading