feat(web): Ingo UX — Proaktivität, Oo-Gesicht, dezentes Audio (#76)
* feat(web): Ingo UX — proactivity signals, Oo face richness, soft audio Priority S: Heute Brief buckets (Needs you ≤3 / Doing / Done), roster doing-verbs and Braucht-dich chips, sidebar badge on Heute, need-you ping on waiting states. Priority A/B: Web Audio cues (app-start shimmer, interact tick, success-resolve, need-you ouh), reaction vocals with cooldown, optional eOo cold-open voice mark. Oo eyes gain iris/specular detail and asymmetric blink; hands reduced to rest except punctuation poses; speaking mouth animates. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): biome format and unused import in HeuteBrief Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): a11y and import order for Heute badge Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * docs+motion: Motion God craft — pose stagger, audio bus, ducking Add MOTION-EVOO-CHARACTER.md (Brand Soul brief): eyes lead, mouth ~80%, hands punctuation, 40–80 ms eye→mouth lag, 220–320 ms springs, SFX LUFS and duck-under-TTS/PTT, nod mh/Mhh, reduced-motion rules. Implement motion-tokens, staggered mouthPose in EvooMascot, shared oo-audio-bus with voice-lane ducking, mint→gold success, hm nod vocal. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): align conversation roster with bot-only sidebar groups after rebase Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
8ca23ad609
commit
5ae8f15931
23
VOICE.md
23
VOICE.md
@ -114,19 +114,24 @@ Voice chrome uses `text-ui-*`, `size-icon-*`, and `h-control-*` / `size-control-
|
||||
|
||||
## Sound language
|
||||
|
||||
Procedural Web Audio cues (`apps/web/src/lib/oo-sound.ts`) — no sample packs:
|
||||
Procedural Web Audio (`oo-sound.ts`, `oo-vocals.ts`, `oo-audio-bus.ts`) — no sample packs. Full motion/SFX spec: `packages/ui-web/MOTION-EVOO-CHARACTER.md`.
|
||||
|
||||
| Cue | Trigger |
|
||||
|-----|---------|
|
||||
| `work-complete` | Run transitions from active → finished (text mode) |
|
||||
| `listening-start` / `listening-stop` | Voice phase enters/leaves listening |
|
||||
| `speaking-start` / `speaking-done` | TTS playback lifecycle |
|
||||
| `attention` | Run error surfaced in composer |
|
||||
| `message-sent` | Successful composer send |
|
||||
| `handoff` | Handoff block appears in thread |
|
||||
| `group-spark` | Peer Oo reply in a group thread — playful staccato |
|
||||
| `app-start` | Cold open — mint→violet shimmer |
|
||||
| `interact` | Primary nav / roster taps |
|
||||
| `success-resolve` | Run finished — mint→gold |
|
||||
| `need-you` | `waiting_input` / takeover — with `ouh` vocal |
|
||||
| `work-complete` | Alias of `success-resolve` |
|
||||
| `listening-start` / `listening-stop` | Voice phase |
|
||||
| `speaking-start` / `speaking-done` | TTS lifecycle |
|
||||
| `attention` | Run error in composer |
|
||||
| `message-sent` | Composer send |
|
||||
| `handoff` / `group-spark` | Thread collaboration |
|
||||
|
||||
Toggle: **Settings → Voice → Sound feedback**. Respects `prefers-reduced-motion: reduce` and hidden tabs.
|
||||
Reaction vocals (`mh` / `Mhh` nod, `ouh`, `hm` on success): state-change only, 8–12 s cooldown, **−18…−12 LUFS** via shared bus, **duck under TTS/PTT**.
|
||||
|
||||
Toggle: **Settings → Voice → Sound feedback**. `prefers-reduced-motion: reduce` → no vocals/SFX loops; character visual state only.
|
||||
|
||||
## Current slice (this PR)
|
||||
|
||||
|
||||
42
apps/web/src/lib/agent-activity.test.ts
Normal file
42
apps/web/src/lib/agent-activity.test.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isDoingStatus,
|
||||
isNeedsYouStatus,
|
||||
needsYouChipLabel,
|
||||
rosterActivitySubtitle,
|
||||
rosterDoingVerb,
|
||||
rosterStatusDot,
|
||||
} from "./agent-activity";
|
||||
|
||||
describe("agent-activity", () => {
|
||||
it("classifies needs-you and doing statuses", () => {
|
||||
expect(isNeedsYouStatus("waiting_input")).toBe(true);
|
||||
expect(isNeedsYouStatus("waiting_takeover")).toBe(true);
|
||||
expect(isNeedsYouStatus("running")).toBe(false);
|
||||
expect(isDoingStatus("running")).toBe(true);
|
||||
expect(isDoingStatus("idle")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns German doing verbs instead of raw status", () => {
|
||||
expect(rosterDoingVerb("waiting_input")).toBe("Braucht deine Antwort");
|
||||
expect(rosterDoingVerb("running")).toBe("Arbeitet…");
|
||||
expect(rosterDoingVerb("idle")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds roster subtitle with verb priority", () => {
|
||||
expect(rosterActivitySubtitle("running", "Some preview")).toBe("Arbeitet…");
|
||||
expect(rosterActivitySubtitle("idle", "Hello there")).toBe("Hello there");
|
||||
expect(rosterActivitySubtitle("idle", "")).toBe("Bereit");
|
||||
});
|
||||
|
||||
it("maps status dot tones", () => {
|
||||
expect(rosterStatusDot("waiting_input")).toBe("needs-you");
|
||||
expect(rosterStatusDot("running")).toBe("working");
|
||||
expect(rosterStatusDot("idle")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns needs-you chip label only for waiting states", () => {
|
||||
expect(needsYouChipLabel("waiting_input")).toBe("Braucht dich");
|
||||
expect(needsYouChipLabel("running")).toBeNull();
|
||||
});
|
||||
});
|
||||
86
apps/web/src/lib/agent-activity.ts
Normal file
86
apps/web/src/lib/agent-activity.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import type { RunActivityRow } from "@rakazo/contracts";
|
||||
|
||||
/** Run statuses where the agent is blocked on the user. */
|
||||
export function isNeedsYouStatus(status: string | undefined): boolean {
|
||||
return status === "waiting_input" || status === "waiting_takeover";
|
||||
}
|
||||
|
||||
/** Run statuses where the agent is actively working (not waiting on user). */
|
||||
export function isDoingStatus(status: string | undefined): boolean {
|
||||
return status === "queued" || status === "leased" || status === "running";
|
||||
}
|
||||
|
||||
export function isActionableRun(run: RunActivityRow): boolean {
|
||||
return isNeedsYouStatus(run.status);
|
||||
}
|
||||
|
||||
export function isFailedRoutine(run: RunActivityRow): boolean {
|
||||
return run.trigger === "routine" && run.status === "failed";
|
||||
}
|
||||
|
||||
/** German doing-verb for roster / activity surfaces — never raw status strings. */
|
||||
export function rosterDoingVerb(status: string | undefined): string | null {
|
||||
switch (status) {
|
||||
case "queued":
|
||||
return "Wartet an…";
|
||||
case "leased":
|
||||
return "Startet…";
|
||||
case "running":
|
||||
return "Arbeitet…";
|
||||
case "waiting_input":
|
||||
return "Braucht deine Antwort";
|
||||
case "waiting_takeover":
|
||||
return "Braucht dich";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Short chip label for needs-you states. */
|
||||
export function needsYouChipLabel(status: string | undefined): string | null {
|
||||
if (!isNeedsYouStatus(status)) return null;
|
||||
return status === "waiting_takeover" ? "Braucht dich" : "Braucht dich";
|
||||
}
|
||||
|
||||
export function runActivityLabel(status: RunActivityRow["status"]): string {
|
||||
switch (status) {
|
||||
case "queued":
|
||||
return "Wartet an…";
|
||||
case "leased":
|
||||
return "Startet…";
|
||||
case "running":
|
||||
return "Arbeitet…";
|
||||
case "waiting_input":
|
||||
return "Braucht deine Antwort";
|
||||
case "waiting_takeover":
|
||||
return "Braucht dich";
|
||||
case "completed":
|
||||
return "Erledigt";
|
||||
case "failed":
|
||||
return "Fehlgeschlagen";
|
||||
case "cancelled":
|
||||
return "Abgebrochen";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Roster subtitle: doing-verb when active, otherwise preview fallback. */
|
||||
export function rosterActivitySubtitle(
|
||||
status: string | undefined,
|
||||
preview: string | undefined,
|
||||
idleFallback = "Bereit",
|
||||
): string {
|
||||
const verb = rosterDoingVerb(status);
|
||||
if (verb) return verb;
|
||||
if (preview) return preview;
|
||||
return idleFallback;
|
||||
}
|
||||
|
||||
export type RosterStatusDot = "working" | "needs-you" | "idle";
|
||||
|
||||
export function rosterStatusDot(status: string | undefined): RosterStatusDot {
|
||||
if (isNeedsYouStatus(status)) return "needs-you";
|
||||
if (isDoingStatus(status)) return "working";
|
||||
return "idle";
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHeuteBrief, isToday } from "./heute-brief";
|
||||
import { buildHeuteBrief, HEUTE_NEEDS_YOU_CAP, isToday } from "./heute-brief";
|
||||
|
||||
const now = new Date("2026-09-22T12:00:00.000Z");
|
||||
|
||||
@ -82,14 +82,17 @@ describe("isToday", () => {
|
||||
});
|
||||
|
||||
describe("buildHeuteBrief", () => {
|
||||
it("collects actionable runs, unread chats, and today follow-ups without duplicates", () => {
|
||||
it("collects needs-you, doing, and done buckets", () => {
|
||||
const brief = buildHeuteBrief({
|
||||
bots: [
|
||||
bot({ id: "bot-1", unread: true }),
|
||||
bot({ id: "bot-2", unread: true, updatedAt: "2026-09-22T08:00:00.000Z", name: "Steve" }),
|
||||
],
|
||||
groups: [group({ updatedAt: "2026-09-22T09:30:00.000Z" })],
|
||||
activeRuns: [run({ runId: "run-1", botId: "bot-1" })],
|
||||
activeRuns: [
|
||||
run({ runId: "run-1", botId: "bot-1" }),
|
||||
run({ runId: "run-3", botId: "bot-4", botName: "Worker", status: "running" }),
|
||||
],
|
||||
recentRuns: [
|
||||
run({
|
||||
runId: "run-2",
|
||||
@ -98,23 +101,28 @@ describe("buildHeuteBrief", () => {
|
||||
status: "failed",
|
||||
trigger: "routine",
|
||||
}),
|
||||
run({
|
||||
runId: "run-4",
|
||||
botId: "bot-5",
|
||||
botName: "DoneBot",
|
||||
status: "completed",
|
||||
updatedAt: "2026-09-22T09:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
now,
|
||||
});
|
||||
|
||||
expect(brief.actionCount).toBe(3);
|
||||
expect(brief.jetzt.map((item) => item.kind)).toEqual(["run", "run", "unread"]);
|
||||
expect(brief.weiter).toHaveLength(1);
|
||||
expect(brief.weiter[0]?.chat.id).toBe("group-1");
|
||||
expect(brief.needsYou.map((item) => item.kind)).toEqual(["run", "run", "unread"]);
|
||||
expect(brief.doing).toHaveLength(1);
|
||||
expect(brief.doing[0]?.run.runId).toBe("run-3");
|
||||
expect(brief.done).toHaveLength(1);
|
||||
expect(brief.done[0]?.run.runId).toBe("run-4");
|
||||
});
|
||||
|
||||
it("caps Weiter at five items", () => {
|
||||
const bots = Array.from({ length: 7 }, (_, index) =>
|
||||
bot({
|
||||
id: `bot-${index}`,
|
||||
name: `Bot ${index}`,
|
||||
updatedAt: `2026-09-22T${String(index).padStart(2, "0")}:00:00.000Z`,
|
||||
}),
|
||||
it("caps needs-you at three items", () => {
|
||||
const bots = Array.from({ length: 5 }, (_, index) =>
|
||||
bot({ id: `bot-${index}`, unread: true, name: `Bot ${index}` }),
|
||||
);
|
||||
const brief = buildHeuteBrief({
|
||||
bots,
|
||||
@ -123,6 +131,7 @@ describe("buildHeuteBrief", () => {
|
||||
recentRuns: [],
|
||||
now,
|
||||
});
|
||||
expect(brief.weiter).toHaveLength(5);
|
||||
expect(brief.needsYou).toHaveLength(HEUTE_NEEDS_YOU_CAP);
|
||||
expect(brief.actionCount).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||
import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity";
|
||||
|
||||
export type JetztRunItem = {
|
||||
kind: "run";
|
||||
@ -13,17 +14,39 @@ export type JetztUnreadItem = {
|
||||
|
||||
export type JetztItem = JetztRunItem | JetztUnreadItem;
|
||||
|
||||
export type DoingItem = {
|
||||
kind: "run";
|
||||
run: RunActivityRow;
|
||||
};
|
||||
|
||||
export type DoneItem = {
|
||||
kind: "run";
|
||||
run: RunActivityRow;
|
||||
};
|
||||
|
||||
/** @deprecated Use needsYou — kept for gradual migration in tests. */
|
||||
export type WeiterItem = {
|
||||
chatKind: "bot" | "group";
|
||||
chat: Bot | Group;
|
||||
};
|
||||
|
||||
export type HeuteBriefData = {
|
||||
jetzt: JetztItem[];
|
||||
weiter: WeiterItem[];
|
||||
/** Needs you — capped at three visible rows. */
|
||||
needsYou: JetztItem[];
|
||||
/** Active runs where the agent is working, not blocked. */
|
||||
doing: DoingItem[];
|
||||
/** Recently completed runs from today. */
|
||||
done: DoneItem[];
|
||||
/** Total actionable count (may exceed visible cap). */
|
||||
actionCount: number;
|
||||
/** @deprecated Alias for needsYou — legacy bucket name. */
|
||||
jetzt: JetztItem[];
|
||||
/** @deprecated Empty — replaced by doing/done buckets. */
|
||||
weiter: WeiterItem[];
|
||||
};
|
||||
|
||||
export const HEUTE_NEEDS_YOU_CAP = 3;
|
||||
|
||||
export function isToday(iso: string, now = new Date()): boolean {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return false;
|
||||
@ -52,14 +75,6 @@ function chatTargetKey(chatKind: "bot" | "group", chatId: string): string {
|
||||
return chatKind === "group" ? `group:${chatId}` : `bot:${chatId}`;
|
||||
}
|
||||
|
||||
function isActionableRun(run: RunActivityRow): boolean {
|
||||
return run.status === "waiting_input" || run.status === "waiting_takeover";
|
||||
}
|
||||
|
||||
function isFailedRoutine(run: RunActivityRow): boolean {
|
||||
return run.trigger === "routine" && run.status === "failed";
|
||||
}
|
||||
|
||||
export function buildHeuteBrief(params: {
|
||||
bots: Bot[];
|
||||
groups: Group[];
|
||||
@ -68,7 +83,7 @@ export function buildHeuteBrief(params: {
|
||||
now?: Date;
|
||||
}): HeuteBriefData {
|
||||
const { bots, groups, activeRuns, recentRuns, now = new Date() } = params;
|
||||
const jetzt: JetztItem[] = [];
|
||||
const needsYouAll: JetztItem[] = [];
|
||||
const coveredTargets = new Set<string>();
|
||||
|
||||
const actionableRuns = [
|
||||
@ -79,43 +94,46 @@ export function buildHeuteBrief(params: {
|
||||
for (const run of actionableRuns) {
|
||||
if (seenRunIds.has(run.runId)) continue;
|
||||
seenRunIds.add(run.runId);
|
||||
jetzt.push({ kind: "run", run });
|
||||
needsYouAll.push({ kind: "run", run });
|
||||
coveredTargets.add(runTargetKey(run));
|
||||
}
|
||||
|
||||
const unreadChats: JetztUnreadItem[] = [
|
||||
...bots.filter((bot) => bot.unread).map((bot) => ({ kind: "unread" as const, chatKind: "bot" as const, chat: bot })),
|
||||
...groups.filter((group) => group.unread).map((group) => ({
|
||||
kind: "unread" as const,
|
||||
chatKind: "group" as const,
|
||||
chat: group,
|
||||
})),
|
||||
...bots
|
||||
.filter((bot) => bot.unread)
|
||||
.map((bot) => ({ kind: "unread" as const, chatKind: "bot" as const, chat: bot })),
|
||||
...groups
|
||||
.filter((group) => group.unread)
|
||||
.map((group) => ({
|
||||
kind: "unread" as const,
|
||||
chatKind: "group" as const,
|
||||
chat: group,
|
||||
})),
|
||||
];
|
||||
for (const item of unreadChats) {
|
||||
const key = chatTargetKey(item.chatKind, item.chat.id);
|
||||
if (coveredTargets.has(key)) continue;
|
||||
jetzt.push(item);
|
||||
needsYouAll.push(item);
|
||||
coveredTargets.add(key);
|
||||
}
|
||||
|
||||
const weiterCandidates: WeiterItem[] = [
|
||||
...bots
|
||||
.filter((bot) => isToday(bot.updatedAt, now))
|
||||
.map((bot) => ({ chatKind: "bot" as const, chat: bot })),
|
||||
...groups
|
||||
.filter((group) => isToday(group.updatedAt, now))
|
||||
.map((group) => ({ chatKind: "group" as const, chat: group })),
|
||||
];
|
||||
weiterCandidates.sort(
|
||||
(a, b) => new Date(b.chat.updatedAt).getTime() - new Date(a.chat.updatedAt).getTime(),
|
||||
);
|
||||
const weiter = weiterCandidates
|
||||
.filter((item) => !coveredTargets.has(chatTargetKey(item.chatKind, item.chat.id)))
|
||||
.slice(0, 5);
|
||||
const doing: DoingItem[] = activeRuns
|
||||
.filter((run) => isDoingStatus(run.status))
|
||||
.map((run) => ({ kind: "run" as const, run }));
|
||||
|
||||
const done: DoneItem[] = recentRuns
|
||||
.filter((run) => run.status === "completed" && isToday(run.updatedAt, now))
|
||||
.slice(0, 5)
|
||||
.map((run) => ({ kind: "run" as const, run }));
|
||||
|
||||
const needsYou = needsYouAll.slice(0, HEUTE_NEEDS_YOU_CAP);
|
||||
|
||||
return {
|
||||
jetzt,
|
||||
weiter,
|
||||
actionCount: jetzt.length,
|
||||
needsYou,
|
||||
doing,
|
||||
done,
|
||||
actionCount: needsYouAll.length,
|
||||
jetzt: needsYouAll,
|
||||
weiter: [],
|
||||
};
|
||||
}
|
||||
|
||||
40
apps/web/src/lib/oo-audio-bus.test.ts
Normal file
40
apps/web/src/lib/oo-audio-bus.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ooAudioBus } from "./oo-audio-bus";
|
||||
|
||||
describe("ooAudioBus", () => {
|
||||
class FakeContext {
|
||||
state: AudioContextState = "running";
|
||||
currentTime = 0;
|
||||
destination = {};
|
||||
createGain() {
|
||||
const gain = {
|
||||
value: 0,
|
||||
cancelScheduledValues: vi.fn(),
|
||||
setValueAtTime: vi.fn(function (this: { value: number }, v: number) {
|
||||
this.value = v;
|
||||
}),
|
||||
linearRampToValueAtTime: vi.fn(function (this: { value: number }, v: number) {
|
||||
this.value = v;
|
||||
}),
|
||||
};
|
||||
return { gain, connect: vi.fn() };
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("AudioContext", FakeContext);
|
||||
ooAudioBus.resetVoiceHoldForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("ducks SFX when voice lane is active", () => {
|
||||
ooAudioBus.ensureContext();
|
||||
ooAudioBus.setVoiceLaneActive(true);
|
||||
ooAudioBus.setVoiceLaneActive(true);
|
||||
ooAudioBus.setVoiceLaneActive(false);
|
||||
expect(() => ooAudioBus.setVoiceLaneActive(false)).not.toThrow();
|
||||
});
|
||||
});
|
||||
78
apps/web/src/lib/oo-audio-bus.ts
Normal file
78
apps/web/src/lib/oo-audio-bus.ts
Normal file
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Shared procedural SFX bus — −18…−12 LUFS ballpark, ducks under TTS/PTT.
|
||||
* See packages/ui-web/MOTION-EVOO-CHARACTER.md
|
||||
*/
|
||||
|
||||
type AudioContextCtor = new () => AudioContext;
|
||||
|
||||
/** Master output ~−15 LUFS for short procedural peaks. */
|
||||
const SFX_MASTER_GAIN = 0.58;
|
||||
/** Duck depth when voice lane active (TTS or mic wash). */
|
||||
const SFX_DUCK_RATIO = 0.28;
|
||||
const DUCK_RAMP_S = 0.065;
|
||||
|
||||
function audioContextCtor(): AudioContextCtor | undefined {
|
||||
if (typeof AudioContext === "function") return AudioContext;
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const host = window as Window & { webkitAudioContext?: AudioContextCtor };
|
||||
return host.webkitAudioContext;
|
||||
}
|
||||
|
||||
class OoAudioBus {
|
||||
private ctx: AudioContext | null = null;
|
||||
private master: GainNode | null = null;
|
||||
private sfxGain: GainNode | null = null;
|
||||
private voiceHold = 0;
|
||||
|
||||
ensureContext(): AudioContext | null {
|
||||
if (this.ctx && this.ctx.state !== "closed") return this.ctx;
|
||||
const Ctor = audioContextCtor();
|
||||
if (!Ctor) return null;
|
||||
try {
|
||||
const ctx = new Ctor();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 1;
|
||||
master.connect(ctx.destination);
|
||||
const sfxGain = ctx.createGain();
|
||||
sfxGain.gain.value = SFX_MASTER_GAIN;
|
||||
sfxGain.connect(master);
|
||||
this.ctx = ctx;
|
||||
this.master = master;
|
||||
this.sfxGain = sfxGain;
|
||||
return ctx;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Route for oo-sound / oo-vocals / oo-voice-mark oscillators. */
|
||||
sfxDestination(ctx: AudioContext): AudioNode {
|
||||
if (ctx !== this.ctx) {
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = SFX_MASTER_GAIN;
|
||||
master.connect(ctx.destination);
|
||||
return master;
|
||||
}
|
||||
return this.sfxGain ?? ctx.destination;
|
||||
}
|
||||
|
||||
/** TTS playback or PTT/listening — duck SFX so vocals don't fight the wash. */
|
||||
setVoiceLaneActive(active: boolean) {
|
||||
const ctx = this.ensureContext();
|
||||
if (!ctx || !this.sfxGain) return;
|
||||
this.voiceHold = Math.max(0, this.voiceHold + (active ? 1 : -1));
|
||||
const t = ctx.currentTime;
|
||||
const target = this.voiceHold > 0 ? SFX_MASTER_GAIN * SFX_DUCK_RATIO : SFX_MASTER_GAIN;
|
||||
this.sfxGain.gain.cancelScheduledValues(t);
|
||||
this.sfxGain.gain.setValueAtTime(this.sfxGain.gain.value, t);
|
||||
this.sfxGain.gain.linearRampToValueAtTime(target, t + DUCK_RAMP_S);
|
||||
}
|
||||
|
||||
/** Test helper — reset duck state. */
|
||||
resetVoiceHoldForTests() {
|
||||
this.voiceHold = 0;
|
||||
if (this.sfxGain) this.sfxGain.gain.value = SFX_MASTER_GAIN;
|
||||
}
|
||||
}
|
||||
|
||||
export const ooAudioBus = new OoAudioBus();
|
||||
21
apps/web/src/lib/oo-audio-voice-wire.ts
Normal file
21
apps/web/src/lib/oo-audio-voice-wire.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { dictation } from "./dictation";
|
||||
import { ooAudioBus } from "./oo-audio-bus";
|
||||
import { speaker } from "./tts";
|
||||
|
||||
/** Duck procedural SFX under TTS playback and PTT/listening wash. */
|
||||
export function wireOoAudioVoiceDucking(): () => void {
|
||||
const unsubSpeaker = speaker.subscribe((snapshot) => {
|
||||
ooAudioBus.setVoiceLaneActive(
|
||||
snapshot.status === "preparing" || snapshot.status === "speaking",
|
||||
);
|
||||
});
|
||||
const unsubDictation = dictation.subscribe((snapshot) => {
|
||||
ooAudioBus.setVoiceLaneActive(
|
||||
snapshot.status === "listening" || snapshot.status === "transcribing",
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubSpeaker();
|
||||
unsubDictation();
|
||||
};
|
||||
}
|
||||
@ -79,6 +79,10 @@ describe("ooSound", () => {
|
||||
|
||||
it("plays procedural cues when enabled", () => {
|
||||
const cues: OoSoundCue[] = [
|
||||
"app-start",
|
||||
"interact",
|
||||
"success-resolve",
|
||||
"need-you",
|
||||
"work-complete",
|
||||
"listening-start",
|
||||
"listening-stop",
|
||||
@ -87,6 +91,7 @@ describe("ooSound", () => {
|
||||
"attention",
|
||||
"message-sent",
|
||||
"handoff",
|
||||
"group-spark",
|
||||
];
|
||||
for (const cue of cues) {
|
||||
expect(() => ooSound.play(cue)).not.toThrow();
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { ooAudioBus } from "./oo-audio-bus";
|
||||
import { readOoSoundEnabled, writeOoSoundEnabled } from "./oo-sound-pref";
|
||||
|
||||
export type OoSoundCue =
|
||||
| "app-start"
|
||||
| "interact"
|
||||
| "success-resolve"
|
||||
| "need-you"
|
||||
| "work-complete"
|
||||
| "listening-start"
|
||||
| "listening-stop"
|
||||
@ -11,15 +16,6 @@ export type OoSoundCue =
|
||||
| "handoff"
|
||||
| "group-spark";
|
||||
|
||||
type AudioContextCtor = new () => AudioContext;
|
||||
|
||||
function audioContextCtor(): AudioContextCtor | undefined {
|
||||
if (typeof AudioContext === "function") return AudioContext;
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const host = window as Window & { webkitAudioContext?: AudioContextCtor };
|
||||
return host.webkitAudioContext;
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
@ -54,17 +50,57 @@ function tone(
|
||||
osc.stop(startAt + duration);
|
||||
}
|
||||
|
||||
function sweep(
|
||||
ctx: AudioContext,
|
||||
destination: AudioNode,
|
||||
fromHz: number,
|
||||
toHz: number,
|
||||
startAt: number,
|
||||
duration: number,
|
||||
options: { gain?: number; type?: OscillatorType } = {},
|
||||
) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
const peak = options.gain ?? 0.05;
|
||||
osc.type = options.type ?? "sine";
|
||||
osc.frequency.setValueAtTime(fromHz, startAt);
|
||||
osc.frequency.exponentialRampToValueAtTime(Math.max(toHz, 1), startAt + duration * 0.85);
|
||||
gain.gain.setValueAtTime(0.0001, startAt);
|
||||
gain.gain.exponentialRampToValueAtTime(peak, startAt + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
|
||||
osc.connect(gain);
|
||||
gain.connect(destination);
|
||||
osc.start(startAt);
|
||||
osc.stop(startAt + duration);
|
||||
}
|
||||
|
||||
function playCueOnContext(ctx: AudioContext, cue: OoSoundCue) {
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.85;
|
||||
master.connect(ctx.destination);
|
||||
const master = ooAudioBus.sfxDestination(ctx);
|
||||
const t0 = ctx.currentTime + 0.01;
|
||||
|
||||
switch (cue) {
|
||||
case "app-start":
|
||||
sweep(ctx, master, 523.25, 659.25, t0, 0.14, { gain: 0.038, type: "sine" });
|
||||
sweep(ctx, master, 659.25, 493.88, t0 + 0.1, 0.18, { gain: 0.032, type: "triangle" });
|
||||
break;
|
||||
case "interact":
|
||||
tone(ctx, master, 1046.5, t0, 0.06, {
|
||||
gain: 0.018,
|
||||
attack: 0.003,
|
||||
release: 0.04,
|
||||
type: "triangle",
|
||||
});
|
||||
break;
|
||||
case "success-resolve":
|
||||
case "work-complete":
|
||||
tone(ctx, master, 392, t0, 0.42, { gain: 0.07 });
|
||||
tone(ctx, master, 523.25, t0 + 0.09, 0.48, { gain: 0.06 });
|
||||
tone(ctx, master, 659.25, t0 + 0.18, 0.62, { gain: 0.05, type: "triangle" });
|
||||
// mint → gold resolve (~270 ms)
|
||||
sweep(ctx, master, 523.25, 659.25, t0, 0.12, { gain: 0.042, type: "sine" });
|
||||
sweep(ctx, master, 659.25, 783.99, t0 + 0.1, 0.22, { gain: 0.036, type: "triangle" });
|
||||
tone(ctx, master, 880, t0 + 0.2, 0.28, { gain: 0.03, type: "sine" });
|
||||
break;
|
||||
case "need-you":
|
||||
sweep(ctx, master, 220, 330, t0, 0.1, { gain: 0.042, type: "triangle" });
|
||||
tone(ctx, master, 277.18, t0 + 0.09, 0.12, { gain: 0.035, release: 0.08 });
|
||||
break;
|
||||
case "listening-start":
|
||||
tone(ctx, master, 220, t0, 0.22, { gain: 0.045, type: "triangle" });
|
||||
@ -102,7 +138,6 @@ function playCueOnContext(ctx: AudioContext, cue: OoSoundCue) {
|
||||
}
|
||||
|
||||
class OoSoundEngine {
|
||||
private ctx: AudioContext | null = null;
|
||||
private enabled = readOoSoundEnabled();
|
||||
|
||||
isEnabled(): boolean {
|
||||
@ -114,24 +149,12 @@ class OoSoundEngine {
|
||||
writeOoSoundEnabled(next);
|
||||
}
|
||||
|
||||
private ensureContext(): AudioContext | null {
|
||||
if (this.ctx && this.ctx.state !== "closed") return this.ctx;
|
||||
const Ctor = audioContextCtor();
|
||||
if (!Ctor) return null;
|
||||
try {
|
||||
this.ctx = new Ctor();
|
||||
return this.ctx;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
play(cue: OoSoundCue) {
|
||||
if (!this.enabled) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
|
||||
const ctx = this.ensureContext();
|
||||
const ctx = ooAudioBus.ensureContext();
|
||||
if (!ctx) return;
|
||||
|
||||
const run = () => {
|
||||
|
||||
176
apps/web/src/lib/oo-vocals.ts
Normal file
176
apps/web/src/lib/oo-vocals.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import { ooAudioBus } from "./oo-audio-bus";
|
||||
import { readOoSoundEnabled } from "./oo-sound-pref";
|
||||
|
||||
export type OoVocalReaction = "ouh" | "mhm" | "aaah" | "mm" | "nod-down" | "nod-up" | "hm";
|
||||
|
||||
const COOLDOWN_MIN_MS = 8_000;
|
||||
const COOLDOWN_MAX_MS = 12_000;
|
||||
|
||||
let lastPlayedAt = 0;
|
||||
let nextCooldownMs = COOLDOWN_MIN_MS;
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
function randomCooldown(): number {
|
||||
return COOLDOWN_MIN_MS + Math.random() * (COOLDOWN_MAX_MS - COOLDOWN_MIN_MS);
|
||||
}
|
||||
|
||||
function vocalFormant(
|
||||
ctx: AudioContext,
|
||||
destination: AudioNode,
|
||||
baseHz: number,
|
||||
startAt: number,
|
||||
duration: number,
|
||||
gainPeak: number,
|
||||
) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = "bandpass";
|
||||
filter.frequency.value = baseHz * 2.4;
|
||||
filter.Q.value = 4.5;
|
||||
osc.type = "triangle";
|
||||
osc.frequency.setValueAtTime(baseHz, startAt);
|
||||
gain.gain.setValueAtTime(0.0001, startAt);
|
||||
gain.gain.exponentialRampToValueAtTime(gainPeak, startAt + 0.025);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(destination);
|
||||
osc.start(startAt);
|
||||
osc.stop(startAt + duration + 0.02);
|
||||
}
|
||||
|
||||
function playReactionOnContext(ctx: AudioContext, reaction: OoVocalReaction) {
|
||||
const master = ooAudioBus.sfxDestination(ctx);
|
||||
const t0 = ctx.currentTime + 0.01;
|
||||
|
||||
switch (reaction) {
|
||||
case "ouh":
|
||||
vocalFormant(ctx, master, 280, t0, 0.14, 0.055);
|
||||
vocalFormant(ctx, master, 360, t0 + 0.06, 0.1, 0.04);
|
||||
break;
|
||||
case "mhm":
|
||||
vocalFormant(ctx, master, 180, t0, 0.12, 0.042);
|
||||
vocalFormant(ctx, master, 200, t0 + 0.1, 0.08, 0.032);
|
||||
break;
|
||||
case "aaah":
|
||||
vocalFormant(ctx, master, 320, t0, 0.18, 0.048);
|
||||
vocalFormant(ctx, master, 400, t0 + 0.08, 0.14, 0.038);
|
||||
break;
|
||||
case "mm":
|
||||
vocalFormant(ctx, master, 160, t0, 0.16, 0.04);
|
||||
break;
|
||||
case "hm":
|
||||
vocalFormant(ctx, master, 175, t0, 0.09, 0.038);
|
||||
break;
|
||||
case "nod-down":
|
||||
vocalFormant(ctx, master, 165, t0, 0.07, 0.036);
|
||||
break;
|
||||
case "nod-up":
|
||||
vocalFormant(ctx, master, 205, t0, 0.1, 0.038);
|
||||
vocalFormant(ctx, master, 245, t0 + 0.08, 0.12, 0.032);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
class OoVocalsEngine {
|
||||
/** State-change vocal — respects cooldown and reduced-motion. No loops. */
|
||||
react(reaction: OoVocalReaction) {
|
||||
if (!readOoSoundEnabled()) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastPlayedAt < nextCooldownMs) return;
|
||||
|
||||
const ctx = ooAudioBus.ensureContext();
|
||||
if (!ctx) return;
|
||||
|
||||
const run = () => {
|
||||
try {
|
||||
playReactionOnContext(ctx, reaction);
|
||||
lastPlayedAt = Date.now();
|
||||
nextCooldownMs = randomCooldown();
|
||||
} catch {
|
||||
// Ignore playback failures.
|
||||
}
|
||||
};
|
||||
|
||||
if (ctx.state === "suspended") {
|
||||
void ctx
|
||||
.resume()
|
||||
.then(run)
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
run();
|
||||
}
|
||||
|
||||
/** Nod: mh on down, Mhh on up — single cooldown window. */
|
||||
reactNod() {
|
||||
if (!readOoSoundEnabled()) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastPlayedAt < nextCooldownMs) return;
|
||||
|
||||
const ctx = ooAudioBus.ensureContext();
|
||||
if (!ctx) return;
|
||||
|
||||
const run = () => {
|
||||
try {
|
||||
const t0 = ctx.currentTime + 0.01;
|
||||
const master = ooAudioBus.sfxDestination(ctx);
|
||||
vocalFormant(ctx, master, 165, t0, 0.07, 0.036);
|
||||
vocalFormant(ctx, master, 205, t0 + 0.09, 0.1, 0.038);
|
||||
vocalFormant(ctx, master, 245, t0 + 0.17, 0.12, 0.032);
|
||||
lastPlayedAt = Date.now();
|
||||
nextCooldownMs = randomCooldown();
|
||||
} catch {
|
||||
// Ignore playback failures.
|
||||
}
|
||||
};
|
||||
|
||||
if (ctx.state === "suspended") {
|
||||
void ctx
|
||||
.resume()
|
||||
.then(run)
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
run();
|
||||
}
|
||||
|
||||
reactForEvent(kind: "need-you" | "success" | "nod" | "curious") {
|
||||
switch (kind) {
|
||||
case "need-you":
|
||||
this.react("ouh");
|
||||
break;
|
||||
case "success":
|
||||
this.react("hm");
|
||||
break;
|
||||
case "nod":
|
||||
this.reactNod();
|
||||
break;
|
||||
case "curious":
|
||||
this.react("mm");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ooVocals = new OoVocalsEngine();
|
||||
|
||||
export function resetOoVocalsCooldownForTests(): void {
|
||||
lastPlayedAt = 0;
|
||||
nextCooldownMs = COOLDOWN_MIN_MS;
|
||||
}
|
||||
96
apps/web/src/lib/oo-voice-mark.ts
Normal file
96
apps/web/src/lib/oo-voice-mark.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { ooAudioBus } from "./oo-audio-bus";
|
||||
import { readOoSoundEnabled } from "./oo-sound-pref";
|
||||
|
||||
const SESSION_KEY = "evoo_voice_mark_played";
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
function hasPlayedThisSession(): boolean {
|
||||
if (typeof window === "undefined" || !window.sessionStorage) return true;
|
||||
return window.sessionStorage.getItem(SESSION_KEY) === "1";
|
||||
}
|
||||
|
||||
function markPlayed(): void {
|
||||
if (typeof window === "undefined" || !window.sessionStorage) return;
|
||||
window.sessionStorage.setItem(SESSION_KEY, "1");
|
||||
}
|
||||
|
||||
function playVoiceMarkOnContext(ctx: AudioContext) {
|
||||
const master = ooAudioBus.sfxDestination(ctx);
|
||||
const t0 = ctx.currentTime + 0.01;
|
||||
|
||||
const eOsc = ctx.createOscillator();
|
||||
const eGain = ctx.createGain();
|
||||
const eFilter = ctx.createBiquadFilter();
|
||||
eFilter.type = "bandpass";
|
||||
eFilter.frequency.value = 480;
|
||||
eFilter.Q.value = 5;
|
||||
eOsc.type = "triangle";
|
||||
eOsc.frequency.setValueAtTime(320, t0);
|
||||
eGain.gain.setValueAtTime(0.0001, t0);
|
||||
eGain.gain.exponentialRampToValueAtTime(0.05, t0 + 0.02);
|
||||
eGain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.12);
|
||||
eOsc.connect(eFilter);
|
||||
eFilter.connect(eGain);
|
||||
eGain.connect(master);
|
||||
eOsc.start(t0);
|
||||
eOsc.stop(t0 + 0.14);
|
||||
|
||||
const ooOsc = ctx.createOscillator();
|
||||
const ooGain = ctx.createGain();
|
||||
const ooFilter = ctx.createBiquadFilter();
|
||||
ooFilter.type = "bandpass";
|
||||
ooFilter.frequency.value = 360;
|
||||
ooFilter.Q.value = 4;
|
||||
ooOsc.type = "sine";
|
||||
ooOsc.frequency.setValueAtTime(280, t0 + 0.1);
|
||||
ooOsc.frequency.exponentialRampToValueAtTime(220, t0 + 0.38);
|
||||
ooGain.gain.setValueAtTime(0.0001, t0 + 0.1);
|
||||
ooGain.gain.exponentialRampToValueAtTime(0.055, t0 + 0.14);
|
||||
ooGain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.42);
|
||||
ooOsc.connect(ooFilter);
|
||||
ooFilter.connect(ooGain);
|
||||
ooGain.connect(master);
|
||||
ooOsc.start(t0 + 0.1);
|
||||
ooOsc.stop(t0 + 0.45);
|
||||
}
|
||||
|
||||
class OoVoiceMarkEngine {
|
||||
maybePlayColdOpen() {
|
||||
if (!readOoSoundEnabled()) return;
|
||||
if (prefersReducedMotion()) return;
|
||||
if (hasPlayedThisSession()) return;
|
||||
|
||||
const ctx = ooAudioBus.ensureContext();
|
||||
if (!ctx) return;
|
||||
|
||||
const run = () => {
|
||||
try {
|
||||
playVoiceMarkOnContext(ctx);
|
||||
markPlayed();
|
||||
} catch {
|
||||
// Ignore playback failures.
|
||||
}
|
||||
};
|
||||
|
||||
if (ctx.state === "suspended") {
|
||||
void ctx
|
||||
.resume()
|
||||
.then(run)
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
export const ooVoiceMark = new OoVoiceMarkEngine();
|
||||
|
||||
export function resetOoVoiceMarkForTests(): void {
|
||||
if (typeof window !== "undefined" && window.sessionStorage) {
|
||||
window.sessionStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
}
|
||||
@ -3,13 +3,14 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { RunActivityRow } from "@rakazo/contracts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { isNeedsYouStatus, runActivityLabel } from "../lib/agent-activity";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
function statusTone(status: RunActivityRow["status"]): string {
|
||||
if (status === "failed") return "text-destructive";
|
||||
if (status === "cancelled") return "text-muted-foreground";
|
||||
if (status === "completed") return "text-success";
|
||||
if (status === "waiting_input" || status === "waiting_takeover") return "text-warning";
|
||||
if (isNeedsYouStatus(status)) return "text-warning";
|
||||
return "text-foreground";
|
||||
}
|
||||
|
||||
@ -120,7 +121,13 @@ function ActivityRow({ run, onOpen }: { run: RunActivityRow; onOpen: () => void
|
||||
{run.promptSnippet}
|
||||
</span>
|
||||
) : null}
|
||||
<span className={`ms-auto shrink-0 text-ui-label ${tone}`}>{label}</span>
|
||||
{isNeedsYouStatus(run.status) ? (
|
||||
<span className="ms-auto shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-warning">
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`ms-auto shrink-0 text-ui-label ${tone}`}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@ -142,24 +149,6 @@ function formatRelativeTime(iso: string, now = new Date()): string {
|
||||
}
|
||||
|
||||
function statusLabel(status: RunActivityRow["status"]): string {
|
||||
switch (status) {
|
||||
case "queued":
|
||||
return t`Queued`;
|
||||
case "leased":
|
||||
return t`Starting`;
|
||||
case "running":
|
||||
return t`Running`;
|
||||
case "waiting_input":
|
||||
return t`Needs input`;
|
||||
case "waiting_takeover":
|
||||
return t`Needs takeover`;
|
||||
case "completed":
|
||||
return t`Done`;
|
||||
case "failed":
|
||||
return t`Failed`;
|
||||
case "cancelled":
|
||||
return t`Cancelled`;
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
if (isNeedsYouStatus(status)) return t`Needs you`;
|
||||
return runActivityLabel(status);
|
||||
}
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||
import { botColors } from "@rakazo/ui-tokens";
|
||||
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { runActivityLabel } from "../lib/agent-activity";
|
||||
import {
|
||||
buildHeuteBrief,
|
||||
cleanBriefPreview,
|
||||
type DoingItem,
|
||||
type DoneItem,
|
||||
type JetztItem,
|
||||
type WeiterItem,
|
||||
} from "../lib/heute-brief";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
@ -33,7 +34,6 @@ export function HeuteBrief({
|
||||
onOpenRun,
|
||||
onStartChat,
|
||||
}: HeuteBriefProps) {
|
||||
const { t } = useLingui();
|
||||
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
|
||||
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -79,42 +79,41 @@ export function HeuteBrief({
|
||||
[activeRuns, bots, groups, recentRuns],
|
||||
);
|
||||
|
||||
const botColorById = useMemo(
|
||||
() => new Map(bots.map((bot) => [bot.id, bot.color])),
|
||||
[bots],
|
||||
);
|
||||
const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]);
|
||||
|
||||
const dateLabel = formatHeuteDate(new Date());
|
||||
const hasContent = brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
data-testid="heute-brief"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief">
|
||||
<div className="border-b border-border px-4 py-5 md:px-8 lg:px-10">
|
||||
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">{dateLabel}</h1>
|
||||
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
|
||||
<StatusLine actionCount={brief.actionCount} loading={loading} />
|
||||
<StatusLine
|
||||
actionCount={brief.actionCount}
|
||||
loading={loading}
|
||||
doingCount={brief.doing.length}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
|
||||
{loading && brief.jetzt.length === 0 && brief.weiter.length === 0 ? (
|
||||
{loading && !hasContent ? (
|
||||
<p className="text-ui-body text-muted-foreground">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
) : brief.jetzt.length === 0 && brief.weiter.length === 0 ? (
|
||||
) : !hasContent ? (
|
||||
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
|
||||
) : (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
|
||||
{brief.jetzt.length > 0 ? (
|
||||
<section data-testid="heute-jetzt">
|
||||
{brief.needsYou.length > 0 ? (
|
||||
<section data-testid="heute-needs-you">
|
||||
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||
<Trans>Now</Trans>
|
||||
<Trans>Needs you</Trans>
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{brief.jetzt.map((item) => (
|
||||
<JetztRow
|
||||
{brief.needsYou.map((item) => (
|
||||
<NeedsYouRow
|
||||
key={jetztRowKey(item)}
|
||||
item={item}
|
||||
botColorById={botColorById}
|
||||
@ -124,21 +123,48 @@ export function HeuteBrief({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{brief.actionCount > brief.needsYou.length ? (
|
||||
<p className="mt-2 px-0.5 text-ui-label text-muted-foreground">
|
||||
<Plural
|
||||
value={brief.actionCount - brief.needsYou.length}
|
||||
one="# more waiting"
|
||||
other="# more waiting"
|
||||
/>
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{brief.weiter.length > 0 ? (
|
||||
<section data-testid="heute-weiter">
|
||||
{brief.doing.length > 0 ? (
|
||||
<section data-testid="heute-doing">
|
||||
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||
<Trans>Next</Trans>
|
||||
<Trans>Doing</Trans>
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{brief.weiter.map((item) => (
|
||||
<WeiterRow
|
||||
key={`${item.chatKind}:${item.chat.id}`}
|
||||
{brief.doing.map((item) => (
|
||||
<DoingRow
|
||||
key={`doing:${item.run.runId}`}
|
||||
item={item}
|
||||
onOpenBot={onOpenBot}
|
||||
onOpenGroup={onOpenGroup}
|
||||
botColorById={botColorById}
|
||||
onOpenRun={onOpenRun}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{brief.done.length > 0 ? (
|
||||
<section data-testid="heute-done">
|
||||
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||
<Trans>Done</Trans>
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{brief.done.map((item) => (
|
||||
<DoneRow
|
||||
key={`done:${item.run.runId}`}
|
||||
item={item}
|
||||
botColorById={botColorById}
|
||||
onOpenRun={onOpenRun}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -164,20 +190,25 @@ export function HeuteBrief({
|
||||
);
|
||||
}
|
||||
|
||||
function StatusLine({ actionCount, loading }: { actionCount: number; loading: boolean }) {
|
||||
if (loading && actionCount === 0) {
|
||||
function StatusLine({
|
||||
actionCount,
|
||||
doingCount,
|
||||
loading,
|
||||
}: {
|
||||
actionCount: number;
|
||||
doingCount: number;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (loading && actionCount === 0 && doingCount === 0) {
|
||||
return <Trans>Loading…</Trans>;
|
||||
}
|
||||
if (actionCount === 0) {
|
||||
return <Trans>All quiet</Trans>;
|
||||
if (actionCount > 0) {
|
||||
return <Plural value={actionCount} one="# thing needs you" other="# things need you" />;
|
||||
}
|
||||
return (
|
||||
<Plural
|
||||
value={actionCount}
|
||||
one="# thing needs you"
|
||||
other="# things need you"
|
||||
/>
|
||||
);
|
||||
if (doingCount > 0) {
|
||||
return <Plural value={doingCount} one="# Oo is working" other="# Oos are working" />;
|
||||
}
|
||||
return <Trans>All quiet</Trans>;
|
||||
}
|
||||
|
||||
function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots: boolean }) {
|
||||
@ -202,7 +233,15 @@ function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots:
|
||||
);
|
||||
}
|
||||
|
||||
function JetztRow({
|
||||
function ActionableChip({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-warning">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NeedsYouRow({
|
||||
item,
|
||||
botColorById,
|
||||
onOpenBot,
|
||||
@ -220,13 +259,13 @@ function JetztRow({
|
||||
if (item.kind === "run") {
|
||||
const run = item.run;
|
||||
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
|
||||
const subtitle = run.promptSnippet || runStatusLabel(run.status);
|
||||
const label = t`${title}, ${runStatusLabel(run.status)}`;
|
||||
const subtitle = run.promptSnippet || runActivityLabel(run.status);
|
||||
const label = t`${title}, ${runActivityLabel(run.status)}`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
data-testid={`heute-jetzt-run-${run.runId}`}
|
||||
data-testid={`heute-needs-you-run-${run.runId}`}
|
||||
onClick={() => onOpenRun(run)}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-3 py-3 text-start transition-colors hover:border-border hover:bg-accent"
|
||||
>
|
||||
@ -234,14 +273,13 @@ function JetztRow({
|
||||
color={botColorById.get(run.botId) ?? botColors[3]}
|
||||
identity={run.botId}
|
||||
size={40}
|
||||
status={run.status}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-ui-body font-semibold text-foreground">{title}</div>
|
||||
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{subtitle}</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-ui-label font-medium text-warning">
|
||||
{runStatusLabel(run.status)}
|
||||
</span>
|
||||
<ActionableChip label={t`Needs you`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -255,11 +293,9 @@ function JetztRow({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
data-testid={`heute-jetzt-unread-${chatKind}-${chat.id}`}
|
||||
data-testid={`heute-needs-you-unread-${chatKind}-${chat.id}`}
|
||||
onClick={() =>
|
||||
chatKind === "bot"
|
||||
? onOpenBot(chat.id, chat.spaceId)
|
||||
: onOpenGroup(chat.id, chat.spaceId)
|
||||
chatKind === "bot" ? onOpenBot(chat.id, chat.spaceId) : onOpenGroup(chat.id, chat.spaceId)
|
||||
}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-3 py-3 text-start transition-colors hover:border-border hover:bg-accent"
|
||||
>
|
||||
@ -277,62 +313,84 @@ function JetztRow({
|
||||
<div className="truncate text-ui-body font-semibold text-foreground">{title}</div>
|
||||
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{preview}</div>
|
||||
</div>
|
||||
<span className="inline-block size-2 shrink-0 rounded-full bg-primary" aria-hidden="true" />
|
||||
<ActionableChip label={t`Unread`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function WeiterRow({
|
||||
function DoingRow({
|
||||
item,
|
||||
onOpenBot,
|
||||
onOpenGroup,
|
||||
botColorById,
|
||||
onOpenRun,
|
||||
}: {
|
||||
item: WeiterItem;
|
||||
onOpenBot: (botId: string, spaceId: string) => void;
|
||||
onOpenGroup: (groupId: string, spaceId: string) => void;
|
||||
item: DoingItem;
|
||||
botColorById: Map<string, string>;
|
||||
onOpenRun: (run: RunActivityRow) => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const { chatKind, chat } = item;
|
||||
const title = chat.name;
|
||||
const preview =
|
||||
cleanBriefPreview(chat.preview) ||
|
||||
(chatKind === "group"
|
||||
? (chat as Group).members.map((member) => member.name).join(", ")
|
||||
: t`Ready`);
|
||||
const label =
|
||||
chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`;
|
||||
const run = item.run;
|
||||
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
|
||||
const subtitle = run.promptSnippet || runActivityLabel(run.status);
|
||||
const label = t`${title}, ${runActivityLabel(run.status)}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
data-testid={`heute-weiter-${chatKind}-${chat.id}`}
|
||||
onClick={() =>
|
||||
chatKind === "bot"
|
||||
? onOpenBot(chat.id, chat.spaceId)
|
||||
: onOpenGroup(chat.id, chat.spaceId)
|
||||
}
|
||||
data-testid={`heute-doing-run-${run.runId}`}
|
||||
onClick={() => onOpenRun(run)}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-3 py-3 text-start transition-colors hover:border-border hover:bg-accent"
|
||||
>
|
||||
{chatKind === "bot" ? (
|
||||
<BotAvatar
|
||||
color={(chat as Bot).color}
|
||||
identity={chat.id}
|
||||
size={40}
|
||||
status={(chat as Bot).status}
|
||||
/>
|
||||
) : (
|
||||
<GroupAvatar members={(chat as Group).members} size={40} />
|
||||
)}
|
||||
<BotAvatar
|
||||
color={botColorById.get(run.botId) ?? botColors[3]}
|
||||
identity={run.botId}
|
||||
size={40}
|
||||
status={run.status}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-ui-body font-medium text-foreground">{title}</span>
|
||||
<span className="shrink-0 text-ui-label text-muted-foreground">
|
||||
{formatBriefTime(chat.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{preview}</div>
|
||||
<div className="truncate text-ui-body font-medium text-foreground">{title}</div>
|
||||
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{subtitle}</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-ui-label text-muted-foreground">
|
||||
{runActivityLabel(run.status)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DoneRow({
|
||||
item,
|
||||
botColorById,
|
||||
onOpenRun,
|
||||
}: {
|
||||
item: DoneItem;
|
||||
botColorById: Map<string, string>;
|
||||
onOpenRun: (run: RunActivityRow) => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const run = item.run;
|
||||
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
|
||||
const subtitle = run.promptSnippet || runActivityLabel(run.status);
|
||||
const label = t`${title}, ${runActivityLabel(run.status)}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
data-testid={`heute-done-run-${run.runId}`}
|
||||
onClick={() => onOpenRun(run)}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-3 py-3 text-start transition-colors hover:border-border hover:bg-accent"
|
||||
>
|
||||
<BotAvatar
|
||||
color={botColorById.get(run.botId) ?? botColors[3]}
|
||||
identity={run.botId}
|
||||
size={40}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-ui-body font-medium text-foreground">{title}</div>
|
||||
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{subtitle}</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-ui-label text-success">{runActivityLabel(run.status)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -342,19 +400,6 @@ function jetztRowKey(item: JetztItem): string {
|
||||
return `unread:${item.chatKind}:${item.chat.id}`;
|
||||
}
|
||||
|
||||
function runStatusLabel(status: RunActivityRow["status"]): string {
|
||||
switch (status) {
|
||||
case "waiting_input":
|
||||
return t`Needs input`;
|
||||
case "waiting_takeover":
|
||||
return t`Needs takeover`;
|
||||
case "failed":
|
||||
return t`Failed`;
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function formatHeuteDate(date: Date): string {
|
||||
const locale = i18n.locale || "de";
|
||||
return date.toLocaleDateString(locale, {
|
||||
@ -363,10 +408,3 @@ function formatHeuteDate(date: Date): string {
|
||||
month: "long",
|
||||
});
|
||||
}
|
||||
|
||||
function formatBriefTime(iso: string): string {
|
||||
const locale = i18n.locale || "de";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
@ -157,6 +157,7 @@ import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOv
|
||||
import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome";
|
||||
import { useSidebarResize } from "../hooks/useSidebarResize";
|
||||
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
|
||||
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
|
||||
import type { ArtifactTarget } from "../lib/artifact-open";
|
||||
import { authClient } from "../lib/auth";
|
||||
import { takeInitialBootstrap } from "../lib/bootstrap";
|
||||
@ -178,10 +179,14 @@ import {
|
||||
resolveActiveGroupSpeaker,
|
||||
resolveWorkingGroupBots,
|
||||
} from "../lib/group-stage";
|
||||
import { buildHeuteBrief } from "../lib/heute-brief";
|
||||
import { localTimezone } from "../lib/local-timezone";
|
||||
import { copyableMessageText } from "../lib/message-text";
|
||||
import { messageProviderLabel } from "../lib/messaging";
|
||||
import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire";
|
||||
import { ooSound } from "../lib/oo-sound";
|
||||
import { ooVocals } from "../lib/oo-vocals";
|
||||
import { ooVoiceMark } from "../lib/oo-voice-mark";
|
||||
import {
|
||||
isFileDrag,
|
||||
isFilePaste,
|
||||
@ -216,9 +221,9 @@ import {
|
||||
} from "../lib/transcript-scroll";
|
||||
import { speaker } from "../lib/tts";
|
||||
import { ActivityList } from "./ActivityList";
|
||||
import { HeuteBrief } from "./HeuteBrief";
|
||||
import type { ContextMenuPosition } from "./BotContextMenu";
|
||||
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
|
||||
import { HeuteBrief } from "./HeuteBrief";
|
||||
import { HostComputerPrompt } from "./HostComputerPrompt";
|
||||
import { ProjectSessionTimeline } from "./ProjectSessionTimeline";
|
||||
import {
|
||||
@ -366,7 +371,6 @@ export function ShellPage() {
|
||||
return "live";
|
||||
});
|
||||
|
||||
|
||||
const toggleTradingMode = useCallback(() => {
|
||||
setTradingMode((prev) => {
|
||||
const next = prev === "live" ? "paper" : "live";
|
||||
@ -414,6 +418,7 @@ export function ShellPage() {
|
||||
startResizing: startResizingRight,
|
||||
} = useSidebarResize(384, "right", 280, 500);
|
||||
|
||||
const [heuteActionCount, setHeuteActionCount] = useState(0);
|
||||
const [archivedOpen, setArchivedOpen] = useState(false);
|
||||
const [expandedTeamGroups, setExpandedTeamGroups] = useState<Record<string, boolean>>({});
|
||||
const [collapsedSidebarSections, setCollapsedSidebarSections] = useState(() => new Set<string>());
|
||||
@ -734,10 +739,7 @@ export function ShellPage() {
|
||||
? botSections.find((section) => section.id === activeGroup.sectionId)
|
||||
: undefined;
|
||||
const showProjectTimeline =
|
||||
inGroup &&
|
||||
activeNavPillar !== "heute" &&
|
||||
!projectSessionKey &&
|
||||
Boolean(activeGroup?.sectionId);
|
||||
inGroup && activeNavPillar !== "heute" && !projectSessionKey && Boolean(activeGroup?.sectionId);
|
||||
const activePendingAttachments = useMemo(
|
||||
() => attachmentsForThread(pendingAttachments, inGroup ? groupId : active?.id),
|
||||
[active?.id, groupId, inGroup, pendingAttachments],
|
||||
@ -1351,6 +1353,10 @@ export function ShellPage() {
|
||||
}
|
||||
if (event.payload.role === "bot") markBotReadIfVisible(active.id);
|
||||
}
|
||||
if (event.type === "run.waiting_input" || event.type === "computer.takeover.requested") {
|
||||
ooSound.play("need-you");
|
||||
ooVocals.reactForEvent("need-you");
|
||||
}
|
||||
if (
|
||||
isRunTerminalEvent(event) ||
|
||||
event.type === "run.waiting_input" ||
|
||||
@ -1444,6 +1450,10 @@ export function ShellPage() {
|
||||
void refreshBots().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
if (event.type === "run.waiting_input" || event.type === "computer.takeover.requested") {
|
||||
ooSound.play("need-you");
|
||||
ooVocals.reactForEvent("need-you");
|
||||
}
|
||||
if (isRunTerminalEvent(event) || event.type === "run.waiting_input") {
|
||||
// waiting_input: reconcile ask cards if a stale post-send refresh raced SSE.
|
||||
void refreshGroupThread(groupId).catch(() => undefined);
|
||||
@ -1567,7 +1577,10 @@ export function ShellPage() {
|
||||
: [],
|
||||
);
|
||||
entries.push({
|
||||
key: groupsInSpace.length > 0 ? `project-space:${space.id}` : `project-space:${space.id}:empty`,
|
||||
key:
|
||||
groupsInSpace.length > 0
|
||||
? `project-space:${space.id}`
|
||||
: `project-space:${space.id}:empty`,
|
||||
title: space.name,
|
||||
bots: groupsInSpace,
|
||||
sectionId: null,
|
||||
@ -1958,10 +1971,70 @@ export function ShellPage() {
|
||||
const prevTranscriptRunning = useRef(false);
|
||||
const prevRunErrorId = useRef<string | null>(null);
|
||||
const seenHandoffKeys = useRef(new Set<string>());
|
||||
const appSoundStarted = useRef(false);
|
||||
const prevBotStatuses = useRef(new Map<string, string>());
|
||||
|
||||
useEffect(() => wireOoAudioVoiceDucking(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (appSoundStarted.current) return;
|
||||
appSoundStarted.current = true;
|
||||
ooSound.play("app-start");
|
||||
window.setTimeout(() => ooVoiceMark.maybePlayColdOpen(), 280);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const [active, recent] = await Promise.all([
|
||||
rpc.runs.list({ filter: "active" }),
|
||||
rpc.runs.list({ filter: "recent" }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const brief = buildHeuteBrief({
|
||||
bots: botsRef.current,
|
||||
groups,
|
||||
activeRuns: active.runs,
|
||||
recentRuns: recent.runs,
|
||||
});
|
||||
setHeuteActionCount(brief.actionCount);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
timer = window.setTimeout(() => void tick(), 15_000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [groups]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const bot of bots) {
|
||||
const prev = prevBotStatuses.current.get(bot.id);
|
||||
if (
|
||||
prev !== bot.status &&
|
||||
(bot.status === "waiting_input" || bot.status === "waiting_takeover")
|
||||
) {
|
||||
ooSound.play("need-you");
|
||||
ooVocals.reactForEvent("need-you");
|
||||
}
|
||||
prevBotStatuses.current.set(bot.id, bot.status);
|
||||
}
|
||||
}, [bots]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevTranscriptRunning.current && !transcriptRunning && !callOpen) {
|
||||
ooSound.play("work-complete");
|
||||
ooSound.play("success-resolve");
|
||||
ooVocals.reactForEvent("success");
|
||||
}
|
||||
prevTranscriptRunning.current = transcriptRunning;
|
||||
}, [transcriptRunning, callOpen]);
|
||||
@ -3145,7 +3218,11 @@ export function ShellPage() {
|
||||
<div className="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
heuteActionCount > 0 ? t`Heute, ${heuteActionCount} need you` : t`Heute`
|
||||
}
|
||||
onClick={() => {
|
||||
ooSound.play("interact");
|
||||
setActiveNavPillar("heute");
|
||||
setMobileSidebarOpen(false);
|
||||
}}
|
||||
@ -3155,8 +3232,16 @@ export function ShellPage() {
|
||||
: "hover:bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<div className="grid size-control-lg shrink-0 place-items-center rounded-full bg-emerald-500/10 text-emerald-400">
|
||||
<div className="relative grid size-control-lg shrink-0 place-items-center rounded-full bg-emerald-500/10 text-emerald-400">
|
||||
<Calendar strokeWidth={2.2} className="size-icon-md" />
|
||||
{heuteActionCount > 0 ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-0.5 -top-0.5 grid min-w-[18px] place-items-center rounded-full bg-warning px-1 text-[10px] font-bold leading-none text-background"
|
||||
>
|
||||
{heuteActionCount > 9 ? "9+" : heuteActionCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="font-semibold text-foreground/90 tracking-tight text-ui-body">
|
||||
Heute
|
||||
@ -3214,304 +3299,302 @@ export function ShellPage() {
|
||||
</div>
|
||||
) : null}
|
||||
{conversationSidebarGroups.map((group) => {
|
||||
const collapsed =
|
||||
Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
||||
const groupBotIds = group.bots.flatMap((item) =>
|
||||
item.kind === "bot" ? [item.chat.id] : [],
|
||||
);
|
||||
return (
|
||||
<div key={group.key} data-sidebar-group={group.key}>
|
||||
{group.title ? (
|
||||
<div className="flex items-center pt-3 pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
|
||||
onClick={() => {
|
||||
if (group.emptySpaceId) {
|
||||
openSpaceChat(group.emptySpaceId, "/onboarding");
|
||||
return;
|
||||
}
|
||||
toggleSidebarSection(group.key);
|
||||
}}
|
||||
onContextMenu={
|
||||
group.sectionId
|
||||
const collapsed = Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
||||
const groupBotIds = group.bots.flatMap((item) =>
|
||||
item.kind === "bot" ? [item.chat.id] : [],
|
||||
);
|
||||
return (
|
||||
<div key={group.key} data-sidebar-group={group.key}>
|
||||
{group.title ? (
|
||||
<div className="flex items-center pt-3 pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
|
||||
onClick={() => {
|
||||
if (group.emptySpaceId) {
|
||||
openSpaceChat(group.emptySpaceId, "/onboarding");
|
||||
return;
|
||||
}
|
||||
toggleSidebarSection(group.key);
|
||||
}}
|
||||
onContextMenu={
|
||||
group.sectionId
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
// Prefer section rename over delete-space when both apply;
|
||||
// the dedicated space-actions button still opens the space menu.
|
||||
const sections =
|
||||
group.spaceId === bootstrapMe?.spaceId
|
||||
? botSections
|
||||
: (spaces.find((space) => space.id === group.spaceId)
|
||||
?.botSections ?? []);
|
||||
const section = sections.find(
|
||||
(item) => item.id === group.sectionId,
|
||||
);
|
||||
if (!section) return;
|
||||
sectionMenuAnchor.current = event.currentTarget;
|
||||
setSectionMenu({
|
||||
section,
|
||||
spaceId: group.spaceId,
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
}
|
||||
: group.canDeleteSpace
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
// Prefer section rename over delete-space when both apply;
|
||||
// the dedicated space-actions button still opens the space menu.
|
||||
const sections =
|
||||
group.spaceId === bootstrapMe?.spaceId
|
||||
? botSections
|
||||
: (spaces.find((space) => space.id === group.spaceId)
|
||||
?.botSections ?? []);
|
||||
const section = sections.find(
|
||||
(item) => item.id === group.sectionId,
|
||||
);
|
||||
if (!section) return;
|
||||
sectionMenuAnchor.current = event.currentTarget;
|
||||
setSectionMenu({
|
||||
section,
|
||||
spaceId: group.spaceId,
|
||||
spaceMenuAnchor.current = event.currentTarget;
|
||||
setSpaceMenu({
|
||||
id: group.spaceId,
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
}
|
||||
: group.canDeleteSpace
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
spaceMenuAnchor.current = event.currentTarget;
|
||||
setSpaceMenu({
|
||||
id: group.spaceId,
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
aria-expanded={group.emptySpaceId ? undefined : !collapsed}
|
||||
aria-label={
|
||||
group.emptySpaceId
|
||||
? t`Open ${group.title}`
|
||||
: collapsed
|
||||
? t`Expand ${group.title}`
|
||||
: t`Collapse ${group.title}`
|
||||
}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||||
{group.showLock ? (
|
||||
<Lock
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
className="size-icon-sm"
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{group.title}</span>
|
||||
</span>
|
||||
{group.emptySpaceId ? null : (
|
||||
<ChevronDown
|
||||
strokeWidth={1.8}
|
||||
className={`size-icon-md ${collapsed ? "-rotate-90 transition-transform" : "transition-transform"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{group.canDeleteSpace ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t`Actions for ${group.spaceName}`}
|
||||
onClick={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
spaceMenuAnchor.current = event.currentTarget;
|
||||
setSpaceMenu({
|
||||
id: group.spaceId,
|
||||
position: { x: rect.left, y: rect.bottom },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal aria-hidden="true" className="size-icon-md" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!collapsed &&
|
||||
group.bots
|
||||
.slice(0, expandedTeamGroups[group.key] ? undefined : 4)
|
||||
.map((item) => (
|
||||
<button
|
||||
key={`${item.kind}:${item.chat.id}`}
|
||||
type="button"
|
||||
draggable={item.kind === "bot"}
|
||||
data-roster-bot-id={item.kind === "bot" ? item.chat.id : undefined}
|
||||
aria-keyshortcuts={
|
||||
item.kind === "bot" ? "Alt+ArrowUp Alt+ArrowDown" : undefined
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
if (item.kind !== "bot") return;
|
||||
setDraggedBotId(item.chat.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", item.chat.id);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (
|
||||
item.kind === "bot" &&
|
||||
draggedBotId &&
|
||||
groupBotIds.includes(draggedBotId)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (item.kind !== "bot" || !draggedBotId) return;
|
||||
event.preventDefault();
|
||||
reorderRosterBot(draggedBotId, item.chat.id, groupBotIds);
|
||||
setDraggedBotId(null);
|
||||
}}
|
||||
onDragEnd={() => setDraggedBotId(null)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
item.kind !== "bot" ||
|
||||
!event.altKey ||
|
||||
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
|
||||
)
|
||||
return;
|
||||
const index = groupBotIds.indexOf(item.chat.id);
|
||||
const target =
|
||||
groupBotIds[index + (event.key === "ArrowUp" ? -1 : 1)];
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
reorderRosterBot(item.chat.id, target, groupBotIds);
|
||||
}}
|
||||
aria-label={
|
||||
item.kind === "bot"
|
||||
? t`1:1 conversation with ${item.chat.name}`
|
||||
: t`Group conversation ${item.chat.name}`
|
||||
}
|
||||
onClick={() => {
|
||||
setActiveNavPillar("team");
|
||||
openSpaceChat(item.chat.spaceId, `/app/${item.chat.id}`);
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
if (item.chat.spaceId !== bootstrapMe?.spaceId) return;
|
||||
event.preventDefault();
|
||||
botMenuAnchor.current = event.currentTarget;
|
||||
setBotMenu({
|
||||
kind: item.kind,
|
||||
id: item.chat.id,
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${
|
||||
item.kind === "bot" ? "cursor-grab active:cursor-grabbing" : ""
|
||||
} ${
|
||||
!inGroup && active?.id === item.chat.id
|
||||
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
|
||||
: "hover:bg-white/[0.05] border border-transparent"
|
||||
}`}
|
||||
style={{
|
||||
opacity:
|
||||
item.kind === "bot" && draggedBotId === item.chat.id ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<BotAvatar
|
||||
color={item.chat.color}
|
||||
identity={item.chat.id}
|
||||
size={40}
|
||||
status={item.chat.status}
|
||||
/>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[#0b0c10] ${
|
||||
item.chat.status === "running"
|
||||
? "bg-amber-400 animate-pulse"
|
||||
: item.chat.status === "idle"
|
||||
? "bg-emerald-400"
|
||||
: "bg-indigo-400"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<span
|
||||
dir="auto"
|
||||
data-roster-bot-name={item.kind === "bot" ? "" : undefined}
|
||||
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${
|
||||
item.chat.unread
|
||||
? "font-bold text-white"
|
||||
: "font-semibold text-white/95"
|
||||
}`}
|
||||
>
|
||||
{item.chat.name}
|
||||
</span>
|
||||
{item.kind === "bot" && item.chat.title ? (
|
||||
<span className="min-w-0 truncate rounded-full border border-emerald-500/25 bg-emerald-500/10 px-2 py-0.5 text-ui-label font-medium text-emerald-400">
|
||||
{item.chat.title}
|
||||
</span>
|
||||
) : null}
|
||||
{item.chat.unread ? (
|
||||
<span className="sr-only">
|
||||
<Trans> (unread)</Trans>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<span className="text-ui-body-sm text-zinc-400 tabular-nums font-medium">
|
||||
{formatRosterTime(item.chat.updatedAt)}
|
||||
</span>
|
||||
{item.chat.unread ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 rounded-full bg-emerald-400"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
dir="auto"
|
||||
className={`mt-0.5 truncate text-ui-body leading-snug ${
|
||||
item.chat.unread ? "font-medium text-white" : "text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{cleanRosterPreview(item.chat.preview) ||
|
||||
(item.chat.status !== "idle" ? item.chat.status : "Bereit")}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{!collapsed && group.bots.length > 4 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedTeamGroups((prev) => ({
|
||||
...prev,
|
||||
[group.key]: !prev[group.key],
|
||||
}))
|
||||
: undefined
|
||||
}
|
||||
aria-expanded={group.emptySpaceId ? undefined : !collapsed}
|
||||
aria-label={
|
||||
group.emptySpaceId
|
||||
? t`Open ${group.title}`
|
||||
: collapsed
|
||||
? t`Expand ${group.title}`
|
||||
: t`Collapse ${group.title}`
|
||||
}
|
||||
className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium text-muted-foreground hover:bg-sidebar-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
{expandedTeamGroups[group.key]
|
||||
? "Weniger anzeigen"
|
||||
: `Alle ${group.bots.length} anzeigen...`}
|
||||
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||||
{group.showLock ? (
|
||||
<Lock strokeWidth={2} aria-hidden="true" className="size-icon-sm" />
|
||||
) : null}
|
||||
<span className="truncate">{group.title}</span>
|
||||
</span>
|
||||
{group.emptySpaceId ? null : (
|
||||
<ChevronDown
|
||||
strokeWidth={1.8}
|
||||
className={`size-icon-md ${collapsed ? "-rotate-90 transition-transform" : "transition-transform"}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="sidebar-projekte">
|
||||
{/* PROJEKTE SECTION */}
|
||||
<div className="flex items-center justify-between pt-4 pb-1 mt-2 border-t border-white/5 px-2.5">
|
||||
<div>
|
||||
<div className="rk-shell-section-label">{t`Projekte`}</div>
|
||||
<p className="mt-0.5 px-0.5 text-ui-caption text-muted-foreground/70">
|
||||
{t`Threads by topic · folders`}
|
||||
</p>
|
||||
{group.canDeleteSpace ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t`Actions for ${group.spaceName}`}
|
||||
onClick={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
spaceMenuAnchor.current = event.currentTarget;
|
||||
setSpaceMenu({
|
||||
id: group.spaceId,
|
||||
position: { x: rect.left, y: rect.bottom },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal aria-hidden="true" className="size-icon-md" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!collapsed &&
|
||||
group.bots
|
||||
.slice(0, expandedTeamGroups[group.key] ? undefined : 4)
|
||||
.map((item) => (
|
||||
<button
|
||||
key={`${item.kind}:${item.chat.id}`}
|
||||
type="button"
|
||||
draggable={item.kind === "bot"}
|
||||
data-roster-bot-id={item.kind === "bot" ? item.chat.id : undefined}
|
||||
aria-keyshortcuts={
|
||||
item.kind === "bot" ? "Alt+ArrowUp Alt+ArrowDown" : undefined
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
if (item.kind !== "bot") return;
|
||||
setDraggedBotId(item.chat.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", item.chat.id);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (
|
||||
item.kind === "bot" &&
|
||||
draggedBotId &&
|
||||
groupBotIds.includes(draggedBotId)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (item.kind !== "bot" || !draggedBotId) return;
|
||||
event.preventDefault();
|
||||
reorderRosterBot(draggedBotId, item.chat.id, groupBotIds);
|
||||
setDraggedBotId(null);
|
||||
}}
|
||||
onDragEnd={() => setDraggedBotId(null)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
item.kind !== "bot" ||
|
||||
!event.altKey ||
|
||||
(event.key !== "ArrowUp" && event.key !== "ArrowDown")
|
||||
)
|
||||
return;
|
||||
const index = groupBotIds.indexOf(item.chat.id);
|
||||
const target =
|
||||
groupBotIds[index + (event.key === "ArrowUp" ? -1 : 1)];
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
reorderRosterBot(item.chat.id, target, groupBotIds);
|
||||
}}
|
||||
aria-label={
|
||||
item.kind === "bot"
|
||||
? t`1:1 conversation with ${item.chat.name}`
|
||||
: t`Group conversation ${item.chat.name}`
|
||||
}
|
||||
onClick={() => {
|
||||
setActiveNavPillar("team");
|
||||
openSpaceChat(item.chat.spaceId, `/app/${item.chat.id}`);
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
if (item.chat.spaceId !== bootstrapMe?.spaceId) return;
|
||||
event.preventDefault();
|
||||
botMenuAnchor.current = event.currentTarget;
|
||||
setBotMenu({
|
||||
kind: item.kind,
|
||||
id: item.chat.id,
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] cursor-grab active:cursor-grabbing ${
|
||||
!inGroup && active?.id === item.chat.id
|
||||
? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30"
|
||||
: "hover:bg-white/[0.05] border border-transparent"
|
||||
}`}
|
||||
style={{
|
||||
opacity: draggedBotId === item.chat.id ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<BotAvatar
|
||||
color={item.chat.color}
|
||||
identity={item.chat.id}
|
||||
size={40}
|
||||
status={item.chat.status}
|
||||
/>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[#0b0c10] ${
|
||||
rosterStatusDot(item.chat.status) === "needs-you"
|
||||
? "bg-warning animate-pulse"
|
||||
: rosterStatusDot(item.chat.status) === "working"
|
||||
? "bg-amber-400 animate-pulse"
|
||||
: "bg-emerald-400"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<span
|
||||
dir="auto"
|
||||
data-roster-bot-name=""
|
||||
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${
|
||||
item.chat.unread
|
||||
? "font-bold text-white"
|
||||
: "font-semibold text-white/95"
|
||||
}`}
|
||||
>
|
||||
{item.chat.name}
|
||||
</span>
|
||||
{needsYouChipLabel(item.chat.status) ? (
|
||||
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-warning">
|
||||
{needsYouChipLabel(item.chat.status)}
|
||||
</span>
|
||||
) : item.chat.title ? (
|
||||
<span className="min-w-0 truncate rounded-full border border-emerald-500/25 bg-emerald-500/10 px-2 py-0.5 text-ui-label font-medium text-emerald-400">
|
||||
{item.chat.title}
|
||||
</span>
|
||||
) : null}
|
||||
{item.chat.unread ? (
|
||||
<span className="sr-only">
|
||||
<Trans> (unread)</Trans>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<span className="text-ui-body-sm text-zinc-400 tabular-nums font-medium">
|
||||
{formatRosterTime(item.chat.updatedAt)}
|
||||
</span>
|
||||
{item.chat.unread ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 rounded-full bg-emerald-400"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
dir="auto"
|
||||
className={`mt-0.5 truncate text-ui-body leading-snug ${
|
||||
item.chat.unread ? "font-medium text-white" : "text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{rosterActivitySubtitle(
|
||||
item.chat.status,
|
||||
cleanRosterPreview(item.chat.preview),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{!collapsed && group.bots.length > 4 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedTeamGroups((prev) => ({
|
||||
...prev,
|
||||
[group.key]: !prev[group.key],
|
||||
}))
|
||||
}
|
||||
className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium text-muted-foreground hover:bg-sidebar-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
{expandedTeamGroups[group.key]
|
||||
? "Weniger anzeigen"
|
||||
: `Alle ${group.bots.length} anzeigen...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="sidebar-projekte">
|
||||
{/* PROJEKTE SECTION */}
|
||||
<div className="flex items-center justify-between pt-4 pb-1 mt-2 border-t border-white/5 px-2.5">
|
||||
<div>
|
||||
<div className="rk-shell-section-label">{t`Projekte`}</div>
|
||||
<p className="mt-0.5 px-0.5 text-ui-caption text-muted-foreground/70">
|
||||
{t`Threads by topic · folders`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Create a project`}
|
||||
onClick={() => openNewSpaceDialog("project")}
|
||||
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
|
||||
>
|
||||
<Plus strokeWidth={2.5} className="size-icon-md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Create a project`}
|
||||
onClick={() => openNewSpaceDialog("project")}
|
||||
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
|
||||
>
|
||||
<Plus strokeWidth={2.5} className="size-icon-md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{projectSidebarGroups.length === 0 ? (
|
||||
<div className="p-4 text-center text-ui-label text-muted-foreground">
|
||||
<p>{t`No projects yet.`}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 text-ui-label"
|
||||
onClick={() => openNewSpaceDialog("project")}
|
||||
>
|
||||
<Plus className="mr-1 size-icon-sm" />
|
||||
{t`Create a project`}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{projectSidebarGroups.map((group) => {
|
||||
{projectSidebarGroups.length === 0 ? (
|
||||
<div className="p-4 text-center text-ui-label text-muted-foreground">
|
||||
<p>{t`No projects yet.`}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 text-ui-label"
|
||||
onClick={() => openNewSpaceDialog("project")}
|
||||
>
|
||||
<Plus className="mr-1 size-icon-sm" />
|
||||
{t`Create a project`}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{projectSidebarGroups.map((group) => {
|
||||
const collapsed =
|
||||
Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
||||
return (
|
||||
@ -3523,7 +3606,9 @@ export function ShellPage() {
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
|
||||
onClick={() => {
|
||||
if (group.emptySpaceId) {
|
||||
const space = spaces.find((item) => item.id === group.emptySpaceId);
|
||||
const space = spaces.find(
|
||||
(item) => item.id === group.emptySpaceId,
|
||||
);
|
||||
openSpaceChat(
|
||||
group.emptySpaceId,
|
||||
space?.hasContent ? "/app" : "/onboarding",
|
||||
@ -3679,7 +3764,7 @@ export function ShellPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@ -4026,11 +4111,7 @@ export function ShellPage() {
|
||||
/>
|
||||
) : inGroup ? (
|
||||
<GroupAvatar
|
||||
members={
|
||||
activeGroup?.members ??
|
||||
activeSnapshot?.members ??
|
||||
[]
|
||||
}
|
||||
members={activeGroup?.members ?? activeSnapshot?.members ?? []}
|
||||
size={36}
|
||||
className="shrink-0"
|
||||
/>
|
||||
@ -4100,7 +4181,6 @@ export function ShellPage() {
|
||||
onStartSession={openProjectChat}
|
||||
onOpenGroupChat={openProjectGroupChat}
|
||||
onStartVoiceFirst={(goalGroupId) => openProjectVoiceFirst(goalGroupId)}
|
||||
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@ -4907,9 +4987,7 @@ export function ShellPage() {
|
||||
await refreshBots();
|
||||
return;
|
||||
}
|
||||
window.location.assign(
|
||||
newSpaceDialogVariant === "project" ? "/app" : "/onboarding",
|
||||
);
|
||||
window.location.assign(newSpaceDialogVariant === "project" ? "/app" : "/onboarding");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
@ -5410,7 +5488,6 @@ const Transcript = memo(function Transcript({
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# evOo Character — Poses & Runtime Mapping
|
||||
|
||||
Motion timing, SFX levels, and expressor hierarchy: **[MOTION-EVOO-CHARACTER.md](./MOTION-EVOO-CHARACTER.md)** (Brand Soul / Motion God).
|
||||
|
||||
## Brand lock (non-negotiable)
|
||||
|
||||
> **Oo trademark = asymmetric Kulleraugen + size difference**
|
||||
|
||||
79
packages/ui-web/MOTION-EVOO-CHARACTER.md
Normal file
79
packages/ui-web/MOTION-EVOO-CHARACTER.md
Normal file
@ -0,0 +1,79 @@
|
||||
# evOo Character Motion — Brand Soul & Motion God
|
||||
|
||||
Product priority: **Proaktivität first** (Oos work or clearly wait on you). Motion and SFX serve that — never decorative idle.
|
||||
|
||||
Cross-ref: geometry lock in [`CHARACTER.md`](./CHARACTER.md), voice encounter in [`../../VOICE.md`](../../VOICE.md).
|
||||
|
||||
---
|
||||
|
||||
## Expressor hierarchy
|
||||
|
||||
| Layer | Role | Share of read |
|
||||
|-------|------|----------------|
|
||||
| **Eyes** | Asymmetric Kulleraugen lead every beat | ~lead |
|
||||
| **Mouth** | Primary expressor | ~80% |
|
||||
| **Hands** | Punctuation only — then rest | ~20% spikes |
|
||||
|
||||
Never symmetrize eyes. Never let floating hands steal focus from face.
|
||||
|
||||
---
|
||||
|
||||
## Pose thesis (character only)
|
||||
|
||||
| Rule | Value |
|
||||
|------|--------|
|
||||
| Eyes before mouth | **40–80 ms** lead on pose change |
|
||||
| Pose transition | **220–320 ms** |
|
||||
| Spring | Cute / soft / snap — `cubic-bezier(0.34, 1.25, 0.64, 1)` |
|
||||
| Scope | Character SVG + mascot only — not app chrome |
|
||||
|
||||
Tokens: `evoo-character/motion-tokens.ts` (`EVOO_MOUTH_LAG_MS`, `EVOO_POSE_TRANSITION_MS`, `EVOO_CHARACTER_SPRING`).
|
||||
|
||||
---
|
||||
|
||||
## SFX & vocals (Web Audio)
|
||||
|
||||
Toggle: Settings → Voice → Sound feedback. No vendor branding in UI.
|
||||
|
||||
| Cue | Spec |
|
||||
|-----|------|
|
||||
| **eOo cold-open** | ≤450 ms, **max 1×/session** |
|
||||
| **App start** | 180–320 ms mint→violet shimmer (before optional eOo mark) |
|
||||
| **Interact** | 40–80 ms soft tick |
|
||||
| **Success** | Mint→gold resolve **or** soft `hm!` vocal — not both loud |
|
||||
| **Need-you** | Short `ouh?!` + UI chip together |
|
||||
| **Reactions** | `mhMhh` / `aaah` / `ouh?!` / `mm` — state-change only |
|
||||
| **Cooldown** | ≥8–12 s between reaction vocals |
|
||||
| **Level** | **−18…−12 LUFS** perceived (procedural peaks via shared bus gain) |
|
||||
| **Duck** | Under TTS playback and PTT/listening wash |
|
||||
|
||||
Implementation: `apps/web/src/lib/oo-sound.ts`, `oo-vocals.ts`, `oo-voice-mark.ts`, `oo-audio-bus.ts`.
|
||||
|
||||
### Nod
|
||||
|
||||
| Phase | Vocal |
|
||||
|-------|--------|
|
||||
| Down | `mh` (short, low) |
|
||||
| Up | `Mhh` (slightly longer, rising) |
|
||||
|
||||
### Dual-Mind bloom
|
||||
|
||||
Reaction vocals may ride the voice-stage cyan/violet bloom — stay **under** the PTT/TTS wash; duck, never compete.
|
||||
|
||||
---
|
||||
|
||||
## Reduced motion
|
||||
|
||||
When `prefers-reduced-motion: reduce`:
|
||||
|
||||
- **No vocal loops** and no reaction stems
|
||||
- **Visual state only** — pose snaps without stagger; no mouth-speak pulse
|
||||
- Procedural UI ticks may still be omitted (same gate as SFX)
|
||||
|
||||
---
|
||||
|
||||
## Proactivity (Priority S)
|
||||
|
||||
Motion never replaces working/waiting signals. Roster doing-verbs, Heute buckets, and need-you pings are authoritative; face/audio **amplify** them.
|
||||
|
||||
See `apps/web/src/lib/agent-activity.ts` and Heute Brief buckets.
|
||||
@ -1,11 +1,12 @@
|
||||
import { memo, useEffect, useId, useRef, useState } from "react";
|
||||
import { EvooCharacterSvg } from "./evoo-character/EvooCharacterSvg.js";
|
||||
import { evooMouthLagMs } from "./evoo-character/motion-tokens.js";
|
||||
import { normalizeEvooPose } from "./evoo-character/pose-map.js";
|
||||
import type { EvooPose } from "./evoo-character/types.js";
|
||||
import { cn } from "./lib/utils.js";
|
||||
|
||||
export type { EvooPose } from "./evoo-character/types.js";
|
||||
export {
|
||||
EVOO_OO_TRADEMARK,
|
||||
mapBotStatusToPose,
|
||||
mapCollaborationToPose,
|
||||
mapOnboardingToPose,
|
||||
@ -13,11 +14,11 @@ export {
|
||||
mapStreamingToPose,
|
||||
mapVoicePhaseToPose,
|
||||
mapWelcomeContextToPose,
|
||||
EVOO_OO_TRADEMARK,
|
||||
normalizeEvooPose,
|
||||
resolveEvooPose,
|
||||
type VoicePresencePhase,
|
||||
} from "./evoo-character/index.js";
|
||||
export type { EvooPose } from "./evoo-character/types.js";
|
||||
|
||||
/** `none` = calm idle only; `hover` = track while pointer is over mascot; `global` = legacy window tracking */
|
||||
export type PointerTracking = "none" | "hover" | "global";
|
||||
@ -74,18 +75,37 @@ export const EvooMascot = memo(function EvooMascot({
|
||||
showWordmark = false,
|
||||
}: EvooMascotProps) {
|
||||
const [activePose, setActivePose] = useState<EvooPose>(initialPose);
|
||||
const [mouthPose, setMouthPose] = useState<EvooPose>(initialPose);
|
||||
const [reducedMotion, setReducedMotion] = useState(false);
|
||||
const [pupilOffset, setPupilOffset] = useState<Point>({ x: 0, y: 0 });
|
||||
const [saccadeOffset, setSaccadeOffset] = useState<Point>({ x: 0, y: 0 });
|
||||
const [isBlinking, setIsBlinking] = useState(false);
|
||||
const [isSmallBlinking, setIsSmallBlinking] = useState(false);
|
||||
const [isWinking, setIsWinking] = useState(false);
|
||||
const [isAsleep, setIsAsleep] = useState(false);
|
||||
const [hapticRipple, setHapticRipple] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const filterId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const sync = () => setReducedMotion(mq.matches);
|
||||
sync();
|
||||
mq.addEventListener("change", sync);
|
||||
return () => mq.removeEventListener("change", sync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setActivePose(initialPose);
|
||||
}, [initialPose]);
|
||||
if (reducedMotion) {
|
||||
setMouthPose(initialPose);
|
||||
return;
|
||||
}
|
||||
const lagMs = evooMouthLagMs();
|
||||
const timer = window.setTimeout(() => setMouthPose(initialPose), lagMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [initialPose, reducedMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
let blinkTimeout: ReturnType<typeof setTimeout>;
|
||||
@ -93,11 +113,18 @@ export const EvooMascot = memo(function EvooMascot({
|
||||
const delay = Math.random() * 4000 + 3000;
|
||||
blinkTimeout = setTimeout(() => {
|
||||
if (!isAsleep) {
|
||||
setIsBlinking(true);
|
||||
setTimeout(() => {
|
||||
setIsBlinking(false);
|
||||
scheduleBlink();
|
||||
}, 140);
|
||||
const roll = Math.random();
|
||||
const mode = roll < 0.55 ? "both" : roll < 0.8 ? "big" : "small";
|
||||
setIsBlinking(mode === "both" || mode === "big");
|
||||
setIsSmallBlinking(mode === "both" || mode === "small");
|
||||
setTimeout(
|
||||
() => {
|
||||
setIsBlinking(false);
|
||||
setIsSmallBlinking(false);
|
||||
scheduleBlink();
|
||||
},
|
||||
mode === "small" ? 110 : 140,
|
||||
);
|
||||
} else {
|
||||
scheduleBlink();
|
||||
}
|
||||
@ -215,7 +242,16 @@ export const EvooMascot = memo(function EvooMascot({
|
||||
const nextPose: EvooPose =
|
||||
canonical === "idle" ? "greeting" : canonical === "greeting" ? "success" : "idle";
|
||||
setActivePose(nextPose);
|
||||
setTimeout(() => setActivePose(initialPose), 2800);
|
||||
if (reducedMotion) {
|
||||
setMouthPose(nextPose);
|
||||
} else {
|
||||
window.setTimeout(() => setMouthPose(nextPose), evooMouthLagMs());
|
||||
}
|
||||
setTimeout(() => {
|
||||
setActivePose(initialPose);
|
||||
if (reducedMotion) setMouthPose(initialPose);
|
||||
else window.setTimeout(() => setMouthPose(initialPose), evooMouthLagMs());
|
||||
}, 2800);
|
||||
};
|
||||
|
||||
const finalPupilX = pupilOffset.x + saccadeOffset.x;
|
||||
@ -274,7 +310,13 @@ export const EvooMascot = memo(function EvooMascot({
|
||||
)}
|
||||
style={{ width: size, height: size * 0.88 }}
|
||||
>
|
||||
<svg viewBox="0 0 140 120" width={size} height={size * 0.86} className="overflow-visible" aria-hidden="true">
|
||||
<svg
|
||||
viewBox="0 0 140 120"
|
||||
width={size}
|
||||
height={size * 0.86}
|
||||
className="overflow-visible"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`${filterId}-aura`} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#00E676" />
|
||||
@ -298,12 +340,15 @@ export const EvooMascot = memo(function EvooMascot({
|
||||
<EvooCharacterSvg
|
||||
filterId={filterId}
|
||||
pose={activePose}
|
||||
mouthPose={mouthPose}
|
||||
pupilX={finalPupilX}
|
||||
pupilY={finalPupilY}
|
||||
isBlinking={isBlinking}
|
||||
isSmallBlinking={isSmallBlinking}
|
||||
isWinking={isWinking}
|
||||
isAsleep={isAsleep}
|
||||
hapticRipple={hapticRipple}
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@ -5,21 +5,32 @@ import {
|
||||
EVOO_PUPIL_BIG_R,
|
||||
EVOO_PUPIL_SMALL_R,
|
||||
} from "./brand-lock.js";
|
||||
import { POSE_DEFINITIONS } from "./poses.js";
|
||||
import { EVOO_CHARACTER_SPRING, EVOO_POSE_TRANSITION_S } from "./motion-tokens.js";
|
||||
import { normalizeEvooPose } from "./pose-map.js";
|
||||
import { POSE_DEFINITIONS } from "./poses.js";
|
||||
import type { EvooMouthStyle, EvooPose } from "./types.js";
|
||||
|
||||
export { EVOO_EYE_BIG_R, EVOO_EYE_SMALL_R, EVOO_PUPIL_BIG_R, EVOO_PUPIL_SMALL_R } from "./brand-lock.js";
|
||||
export {
|
||||
EVOO_EYE_BIG_R,
|
||||
EVOO_EYE_SMALL_R,
|
||||
EVOO_PUPIL_BIG_R,
|
||||
EVOO_PUPIL_SMALL_R,
|
||||
} from "./brand-lock.js";
|
||||
|
||||
export interface EvooCharacterSvgProps {
|
||||
filterId: string;
|
||||
pose: EvooPose;
|
||||
/** Mouth follows pose when omitted; lags eyes by 40–80 ms when set separately. */
|
||||
mouthPose?: EvooPose;
|
||||
pupilX: number;
|
||||
pupilY: number;
|
||||
isBlinking: boolean;
|
||||
isSmallBlinking?: boolean;
|
||||
isWinking: boolean;
|
||||
isAsleep: boolean;
|
||||
hapticRipple?: boolean;
|
||||
/** Skip motion stagger / speak pulse (reduced-motion). */
|
||||
reducedMotion?: boolean;
|
||||
}
|
||||
|
||||
function mouthPath(style: EvooMouthStyle, asleep: boolean): string {
|
||||
@ -43,7 +54,13 @@ function mouthPath(style: EvooMouthStyle, asleep: boolean): string {
|
||||
}
|
||||
}
|
||||
|
||||
function Brow({ side, raise }: { side: "left" | "right"; raise: "left" | "right" | "both" | "worried" }) {
|
||||
function Brow({
|
||||
side,
|
||||
raise,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
raise: "left" | "right" | "both" | "worried";
|
||||
}) {
|
||||
const showLeft = raise === "left" || raise === "both" || raise === "worried";
|
||||
const showRight = raise === "right" || raise === "both" || raise === "worried";
|
||||
const worried = raise === "worried";
|
||||
@ -99,7 +116,16 @@ function Accessory({
|
||||
case "clipboard":
|
||||
return (
|
||||
<g transform="translate(108, 78) rotate(-12)">
|
||||
<rect x="-10" y="-14" width="20" height="26" rx="3" fill="#F8FAFC" stroke="#64748B" strokeWidth="1.2" />
|
||||
<rect
|
||||
x="-10"
|
||||
y="-14"
|
||||
width="20"
|
||||
height="26"
|
||||
rx="3"
|
||||
fill="#F8FAFC"
|
||||
stroke="#64748B"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<rect x="-6" y="-8" width="12" height="2" rx="1" fill="#CBD5E1" />
|
||||
<rect x="-6" y="-3" width="10" height="1.5" rx="0.75" fill="#E2E8F0" />
|
||||
<rect x="-6" y="1" width="8" height="1.5" rx="0.75" fill="#E2E8F0" />
|
||||
@ -126,7 +152,13 @@ function Accessory({
|
||||
return (
|
||||
<g transform="translate(106, 70)">
|
||||
<circle cx="0" cy="0" r="9" fill="none" stroke="#0F172A" strokeWidth="2" opacity="0.7" />
|
||||
<path d="M6 6 L14 14" stroke="#0F172A" strokeWidth="2.5" strokeLinecap="round" opacity="0.7" />
|
||||
<path
|
||||
d="M6 6 L14 14"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
opacity="0.7"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
case "thought":
|
||||
@ -218,38 +250,110 @@ function Hands({
|
||||
case "typing":
|
||||
return (
|
||||
<>
|
||||
<circle cx="38" cy="98" r="7" fill={`url(#${filterId}-quantum-pulse)`} style={{ animation: "evoo-quantum-flicker 0.28s ease-in-out infinite alternate" }} />
|
||||
<circle cx="92" cy="98" r="6.5" fill={`url(#${filterId}-quantum-pulse)`} style={{ animation: "evoo-quantum-flicker 0.28s ease-in-out infinite alternate 0.14s" }} />
|
||||
<GlassHand x={38} y={90} w={20} h={9.5} rotate={0} filterId={filterId} animation="evoo-conductor-left 0.26s cubic-bezier(0.16, 1, 0.3, 1) infinite alternate" origin="38px 90px" />
|
||||
<GlassHand x={92} y={90} w={19} h={9} rotate={0} filterId={filterId} animation="evoo-conductor-right 0.26s cubic-bezier(0.16, 1, 0.3, 1) infinite alternate" origin="92px 90px" />
|
||||
<circle
|
||||
cx="38"
|
||||
cy="98"
|
||||
r="7"
|
||||
fill={`url(#${filterId}-quantum-pulse)`}
|
||||
style={{ animation: "evoo-quantum-flicker 0.28s ease-in-out infinite alternate" }}
|
||||
/>
|
||||
<circle
|
||||
cx="92"
|
||||
cy="98"
|
||||
r="6.5"
|
||||
fill={`url(#${filterId}-quantum-pulse)`}
|
||||
style={{ animation: "evoo-quantum-flicker 0.28s ease-in-out infinite alternate 0.14s" }}
|
||||
/>
|
||||
<GlassHand
|
||||
x={38}
|
||||
y={90}
|
||||
w={20}
|
||||
h={9.5}
|
||||
rotate={0}
|
||||
filterId={filterId}
|
||||
animation="evoo-conductor-left 0.26s cubic-bezier(0.16, 1, 0.3, 1) infinite alternate"
|
||||
origin="38px 90px"
|
||||
/>
|
||||
<GlassHand
|
||||
x={92}
|
||||
y={90}
|
||||
w={19}
|
||||
h={9}
|
||||
rotate={0}
|
||||
filterId={filterId}
|
||||
animation="evoo-conductor-right 0.26s cubic-bezier(0.16, 1, 0.3, 1) infinite alternate"
|
||||
origin="92px 90px"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "peek":
|
||||
return (
|
||||
<>
|
||||
<GlassHand x={36} y={66} w={26} h={13} rotate={-10} filterId={filterId} stroke="rgba(255,255,255,0.85)" />
|
||||
<GlassHand x={90} y={70} w={22} h={11} rotate={10} filterId={filterId} stroke="rgba(255,255,255,0.85)" />
|
||||
<GlassHand
|
||||
x={36}
|
||||
y={66}
|
||||
w={26}
|
||||
h={13}
|
||||
rotate={-10}
|
||||
filterId={filterId}
|
||||
stroke="rgba(255,255,255,0.85)"
|
||||
/>
|
||||
<GlassHand
|
||||
x={90}
|
||||
y={70}
|
||||
w={22}
|
||||
h={11}
|
||||
rotate={10}
|
||||
filterId={filterId}
|
||||
stroke="rgba(255,255,255,0.85)"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "wave":
|
||||
return (
|
||||
<>
|
||||
<GlassHand x={24} y={93} w={20} h={10} rotate={0} filterId={filterId} />
|
||||
<GlassHand x={112} y={64} w={21} h={10.5} rotate={0} filterId={filterId} stroke="rgba(255,255,255,0.85)" animation="evoo-wave-harmonic 0.58s cubic-bezier(0.25, 1, 0.5, 1) infinite alternate" origin="112px 64px" />
|
||||
<GlassHand
|
||||
x={112}
|
||||
y={64}
|
||||
w={21}
|
||||
h={10.5}
|
||||
rotate={0}
|
||||
filterId={filterId}
|
||||
stroke="rgba(255,255,255,0.85)"
|
||||
animation="evoo-wave-harmonic 0.58s cubic-bezier(0.25, 1, 0.5, 1) infinite alternate"
|
||||
origin="112px 64px"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "success":
|
||||
return (
|
||||
<>
|
||||
<GlassHand x={32} y={86} w={22} h={11} rotate={18} filterId={filterId} stroke="#10B981" />
|
||||
<GlassHand x={98} y={86} w={22} h={11} rotate={-18} filterId={filterId} stroke="#10B981" />
|
||||
<GlassHand
|
||||
x={98}
|
||||
y={86}
|
||||
w={22}
|
||||
h={11}
|
||||
rotate={-18}
|
||||
filterId={filterId}
|
||||
stroke="#10B981"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "thinking":
|
||||
return (
|
||||
<>
|
||||
<GlassHand x={27} y={95} w={19} h={9.5} rotate={0} filterId={filterId} />
|
||||
<GlassHand x={86} y={80} w={20} h={10} rotate={-28} filterId={filterId} stroke="rgba(255,255,255,0.85)" />
|
||||
<GlassHand
|
||||
x={86}
|
||||
y={80}
|
||||
w={20}
|
||||
h={10}
|
||||
rotate={-28}
|
||||
filterId={filterId}
|
||||
stroke="rgba(255,255,255,0.85)"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "listening":
|
||||
@ -263,7 +367,16 @@ function Hands({
|
||||
return (
|
||||
<>
|
||||
<GlassHand x={22} y={92} w={20} h={10} rotate={8} filterId={filterId} />
|
||||
<GlassHand x={108} y={78} w={21} h={10.5} rotate={-14} filterId={filterId} animation="evoo-conductor-right 0.5s ease-in-out infinite alternate" origin="108px 78px" />
|
||||
<GlassHand
|
||||
x={108}
|
||||
y={78}
|
||||
w={21}
|
||||
h={10.5}
|
||||
rotate={-14}
|
||||
filterId={filterId}
|
||||
animation="evoo-conductor-right 0.5s ease-in-out infinite alternate"
|
||||
origin="108px 78px"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "reading":
|
||||
@ -303,9 +416,34 @@ function Hands({
|
||||
case "collaborating":
|
||||
return (
|
||||
<>
|
||||
<GlassHand x={34} y={86} w={20} h={10} rotate={-20} filterId={filterId} animation="evoo-conductor-left 0.6s ease-in-out infinite alternate" origin="34px 86px" />
|
||||
<GlassHand x={98} y={86} w={20} h={10} rotate={20} filterId={filterId} animation="evoo-conductor-right 0.6s ease-in-out infinite alternate" origin="98px 86px" />
|
||||
<path d="M58 76 Q66 70 74 76" stroke="#38BDF8" strokeWidth="1.5" fill="none" opacity="0.6" strokeDasharray="3 2" />
|
||||
<GlassHand
|
||||
x={34}
|
||||
y={86}
|
||||
w={20}
|
||||
h={10}
|
||||
rotate={-20}
|
||||
filterId={filterId}
|
||||
animation="evoo-conductor-left 0.6s ease-in-out infinite alternate"
|
||||
origin="34px 86px"
|
||||
/>
|
||||
<GlassHand
|
||||
x={98}
|
||||
y={86}
|
||||
w={20}
|
||||
h={10}
|
||||
rotate={20}
|
||||
filterId={filterId}
|
||||
animation="evoo-conductor-right 0.6s ease-in-out infinite alternate"
|
||||
origin="98px 86px"
|
||||
/>
|
||||
<path
|
||||
d="M58 76 Q66 70 74 76"
|
||||
stroke="#38BDF8"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "error":
|
||||
@ -318,8 +456,28 @@ function Hands({
|
||||
case "delighted":
|
||||
return (
|
||||
<>
|
||||
<GlassHand x={28} y={84} w={22} h={11} rotate={24} filterId={filterId} stroke="#10B981" animation="evoo-wave-harmonic 0.7s ease-in-out infinite alternate" origin="28px 84px" />
|
||||
<GlassHand x={100} y={84} w={22} h={11} rotate={-24} filterId={filterId} stroke="#10B981" animation="evoo-wave-harmonic 0.7s ease-in-out infinite alternate 0.2s" origin="100px 84px" />
|
||||
<GlassHand
|
||||
x={28}
|
||||
y={84}
|
||||
w={22}
|
||||
h={11}
|
||||
rotate={24}
|
||||
filterId={filterId}
|
||||
stroke="#10B981"
|
||||
animation="evoo-wave-harmonic 0.7s ease-in-out infinite alternate"
|
||||
origin="28px 84px"
|
||||
/>
|
||||
<GlassHand
|
||||
x={100}
|
||||
y={84}
|
||||
w={22}
|
||||
h={11}
|
||||
rotate={-24}
|
||||
filterId={filterId}
|
||||
stroke="#10B981"
|
||||
animation="evoo-wave-harmonic 0.7s ease-in-out infinite alternate 0.2s"
|
||||
origin="100px 84px"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
@ -330,16 +488,22 @@ function Hands({
|
||||
export function EvooCharacterSvg({
|
||||
filterId,
|
||||
pose: rawPose,
|
||||
mouthPose: rawMouthPose,
|
||||
pupilX,
|
||||
pupilY,
|
||||
isBlinking,
|
||||
isSmallBlinking = false,
|
||||
isWinking,
|
||||
isAsleep,
|
||||
hapticRipple = false,
|
||||
reducedMotion = false,
|
||||
}: EvooCharacterSvgProps) {
|
||||
const pose = normalizeEvooPose(isAsleep ? "asleep" : rawPose);
|
||||
const mouthNorm = normalizeEvooPose(isAsleep ? "asleep" : (rawMouthPose ?? rawPose));
|
||||
const def = POSE_DEFINITIONS[pose];
|
||||
const mouthDef = POSE_DEFINITIONS[mouthNorm];
|
||||
const gaze = def.gaze ?? { x: 0, y: 0 };
|
||||
const transition = `${EVOO_POSE_TRANSITION_S}s ${EVOO_CHARACTER_SPRING}`;
|
||||
const finalPupilX = pupilX + gaze.x;
|
||||
const finalPupilY = pupilY + gaze.y;
|
||||
const eyeOpen = isAsleep ? 0.38 : (def.eyeOpen ?? 1);
|
||||
@ -355,26 +519,53 @@ export function EvooCharacterSvg({
|
||||
@keyframes evoo-quantum-flicker { 0%, 100% { opacity: 0.3; transform: scale(0.85); } 50% { opacity: 0.85; transform: scale(1.15); } }
|
||||
@keyframes evoo-executive-nod { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(2px); } }
|
||||
@keyframes evoo-haptic-ring { 0% { r: 18; stroke-opacity: 0.7; stroke-width: 2.5; } 100% { r: 56; stroke-opacity: 0; stroke-width: 0.5; } }
|
||||
@keyframes evoo-mouth-speak { 0% { transform: scaleY(0.92); } 100% { transform: scaleY(1.08); } }
|
||||
`}</style>
|
||||
|
||||
{hapticRipple ? (
|
||||
<circle cx="66" cy="56" r="20" fill="none" stroke="#00E676" style={{ animation: "evoo-haptic-ring 0.52s cubic-bezier(0.16, 1, 0.3, 1) forwards" }} />
|
||||
<circle
|
||||
cx="66"
|
||||
cy="56"
|
||||
r="20"
|
||||
fill="none"
|
||||
stroke="#00E676"
|
||||
style={{ animation: "evoo-haptic-ring 0.52s cubic-bezier(0.16, 1, 0.3, 1) forwards" }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{def.aura === "success" ? (
|
||||
<circle cx="66" cy="58" r="48" fill={`url(#${filterId}-solar-core)`} className="animate-pulse" style={{ animationDuration: "1.8s" }} />
|
||||
<circle
|
||||
cx="66"
|
||||
cy="58"
|
||||
r="48"
|
||||
fill={`url(#${filterId}-solar-core)`}
|
||||
className="animate-pulse"
|
||||
style={{ animationDuration: "1.8s" }}
|
||||
/>
|
||||
) : null}
|
||||
{def.aura === "speak" ? (
|
||||
<circle cx="66" cy="58" r="42" fill={`url(#${filterId}-quantum-pulse)`} opacity="0.35" style={{ animation: "evoo-quantum-flicker 2.2s ease-in-out infinite" }} />
|
||||
<circle
|
||||
cx="66"
|
||||
cy="58"
|
||||
r="42"
|
||||
fill={`url(#${filterId}-quantum-pulse)`}
|
||||
opacity="0.35"
|
||||
style={{ animation: "evoo-quantum-flicker 2.2s ease-in-out infinite" }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<path
|
||||
d={mouthPath(def.mouth, isAsleep)}
|
||||
d={mouthPath(mouthDef.mouth, isAsleep)}
|
||||
fill="none"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="2.8"
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
style={{
|
||||
transition: reducedMotion ? "none" : `d ${transition}, transform ${transition}`,
|
||||
...(mouthDef.mouth === "speaking" && !reducedMotion
|
||||
? { animation: "evoo-mouth-speak 0.42s ease-in-out infinite alternate" }
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
|
||||
{def.browRaise ? (
|
||||
@ -388,7 +579,13 @@ export function EvooCharacterSvg({
|
||||
<g
|
||||
className="evoo-eye-big"
|
||||
data-evoo-brand={EVOO_OO_TRADEMARK}
|
||||
style={{ transformOrigin: "44px 54px", animation: def.bodyAnimation === "evoo-executive-nod" ? "evoo-executive-nod 2s ease-in-out infinite" : undefined }}
|
||||
style={{
|
||||
transformOrigin: "44px 54px",
|
||||
animation:
|
||||
def.bodyAnimation === "evoo-executive-nod"
|
||||
? "evoo-executive-nod 2s ease-in-out infinite"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<ellipse
|
||||
cx="44"
|
||||
@ -403,17 +600,53 @@ export function EvooCharacterSvg({
|
||||
filter: "drop-shadow(0 2px 6px rgba(0,0,0,0.22))",
|
||||
transformOrigin: "44px 54px",
|
||||
transform: isBlinking ? "scaleY(0.08)" : `scaleY(${eyeOpen})`,
|
||||
transition: "transform 0.18s cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
transition: reducedMotion ? "none" : `transform ${transition}`,
|
||||
}}
|
||||
/>
|
||||
<ellipse cx="38" cy="44" rx={EVOO_EYE_BIG_R * 0.38} ry={EVOO_EYE_BIG_R * 0.24} fill="#FFFFFF" opacity="0.52" />
|
||||
<ellipse
|
||||
cx="38"
|
||||
cy="44"
|
||||
rx={EVOO_EYE_BIG_R * 0.38}
|
||||
ry={EVOO_EYE_BIG_R * 0.24}
|
||||
fill="#FFFFFF"
|
||||
opacity="0.52"
|
||||
/>
|
||||
{(!isBlinking || isAsleep) && (
|
||||
<g style={{ transform: `translate(${finalPupilX}px, ${finalPupilY}px)`, transition: "transform 0.12s cubic-bezier(0.2, 0.9, 0.4, 1)", opacity: isAsleep ? 0.7 : 1 }}>
|
||||
<g
|
||||
style={{
|
||||
transform: `translate(${finalPupilX}px, ${finalPupilY}px)`,
|
||||
transition: reducedMotion ? "none" : `transform 0.12s ${EVOO_CHARACTER_SPRING}`,
|
||||
opacity: isAsleep ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<ellipse
|
||||
cx="48"
|
||||
cy="57"
|
||||
rx={EVOO_PUPIL_BIG_R + 2.2}
|
||||
ry={EVOO_PUPIL_BIG_R + 2.2}
|
||||
fill="none"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="0.9"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<ellipse cx="48" cy="57" rx={EVOO_PUPIL_BIG_R} ry={EVOO_PUPIL_BIG_R} fill="#0A0F1D" />
|
||||
<ellipse
|
||||
cx="46"
|
||||
cy="59"
|
||||
rx={EVOO_PUPIL_BIG_R * 0.42}
|
||||
ry={EVOO_PUPIL_BIG_R * 0.28}
|
||||
fill="#1E3A5F"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<circle cx="53" cy="49" r="4.6" fill="#FFFFFF" />
|
||||
<circle cx="42" cy="63" r="2.2" fill="#FFFFFF" opacity="0.85" />
|
||||
<ellipse cx="50" cy="51" rx="1.8" ry="1.1" fill="#FFFFFF" opacity="0.92" />
|
||||
{(def.eyeSparkle || pose === "success") && (
|
||||
<path d="M48 45 L50 49 L54 49 L51 52 L52 56 L48 53 L44 56 L45 52 L42 49 L46 49 Z" fill="#10B981" opacity="0.95" />
|
||||
<path
|
||||
d="M48 45 L50 49 L54 49 L51 52 L52 56 L48 53 L44 56 L45 52 L42 49 L46 49 Z"
|
||||
fill="#10B981"
|
||||
opacity="0.95"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
@ -422,7 +655,13 @@ export function EvooCharacterSvg({
|
||||
<g
|
||||
className="evoo-eye-small"
|
||||
data-evoo-brand={EVOO_OO_TRADEMARK}
|
||||
style={{ transformOrigin: "88px 60px", animation: def.bodyAnimation === "evoo-executive-nod" ? "evoo-executive-nod 2s ease-in-out infinite" : undefined }}
|
||||
style={{
|
||||
transformOrigin: "88px 60px",
|
||||
animation:
|
||||
def.bodyAnimation === "evoo-executive-nod"
|
||||
? "evoo-executive-nod 2s ease-in-out infinite"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<ellipse
|
||||
cx="88"
|
||||
@ -436,16 +675,54 @@ export function EvooCharacterSvg({
|
||||
style={{
|
||||
filter: "drop-shadow(0 2px 5px rgba(0,0,0,0.18))",
|
||||
transformOrigin: "88px 60px",
|
||||
transform: isBlinking || isWinking ? "scaleY(0.08)" : `scaleY(${eyeOpen})`,
|
||||
transition: "transform 0.18s cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
transform: isSmallBlinking || isWinking ? "scaleY(0.08)" : `scaleY(${eyeOpen})`,
|
||||
transition: reducedMotion ? "none" : `transform ${transition}`,
|
||||
}}
|
||||
/>
|
||||
<ellipse cx="84.2" cy="52.5" rx={EVOO_EYE_SMALL_R * 0.38} ry={EVOO_EYE_SMALL_R * 0.24} fill="#FFFFFF" opacity="0.48" />
|
||||
<ellipse
|
||||
cx="84.2"
|
||||
cy="52.5"
|
||||
rx={EVOO_EYE_SMALL_R * 0.38}
|
||||
ry={EVOO_EYE_SMALL_R * 0.24}
|
||||
fill="#FFFFFF"
|
||||
opacity="0.48"
|
||||
/>
|
||||
{(!isBlinking || isAsleep) && !isWinking && (
|
||||
<g style={{ transform: `translate(${finalPupilX * 0.85}px, ${finalPupilY * 0.85}px)`, transition: "transform 0.12s cubic-bezier(0.2, 0.9, 0.4, 1)", opacity: isAsleep ? 0.7 : 1 }}>
|
||||
<ellipse cx="90" cy="62" rx={EVOO_PUPIL_SMALL_R} ry={EVOO_PUPIL_SMALL_R} fill="#0A0F1D" />
|
||||
<g
|
||||
style={{
|
||||
transform: `translate(${finalPupilX * 0.85}px, ${finalPupilY * 0.85}px)`,
|
||||
transition: reducedMotion ? "none" : `transform 0.12s ${EVOO_CHARACTER_SPRING}`,
|
||||
opacity: isAsleep ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<ellipse
|
||||
cx="90"
|
||||
cy="62"
|
||||
rx={EVOO_PUPIL_SMALL_R + 1.4}
|
||||
ry={EVOO_PUPIL_SMALL_R + 1.4}
|
||||
fill="none"
|
||||
stroke="#1E293B"
|
||||
strokeWidth="0.7"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<ellipse
|
||||
cx="90"
|
||||
cy="62"
|
||||
rx={EVOO_PUPIL_SMALL_R}
|
||||
ry={EVOO_PUPIL_SMALL_R}
|
||||
fill="#0A0F1D"
|
||||
/>
|
||||
<ellipse
|
||||
cx="88.8"
|
||||
cy="63.2"
|
||||
rx={EVOO_PUPIL_SMALL_R * 0.4}
|
||||
ry={EVOO_PUPIL_SMALL_R * 0.26}
|
||||
fill="#1E3A5F"
|
||||
opacity="0.5"
|
||||
/>
|
||||
<circle cx="93.2" cy="57" r="2.8" fill="#FFFFFF" />
|
||||
<circle cx="86.8" cy="65.5" r="1.3" fill="#FFFFFF" opacity="0.85" />
|
||||
<ellipse cx="91.4" cy="58.2" rx="1.1" ry="0.7" fill="#FFFFFF" opacity="0.88" />
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
@ -454,7 +731,14 @@ export function EvooCharacterSvg({
|
||||
<Accessory kind={def.accessory} filterId={filterId} />
|
||||
) : null}
|
||||
|
||||
<g style={{ animation: def.bodyAnimation && !def.bodyAnimation.includes("flicker") ? `${def.bodyAnimation} 2.2s ease-in-out infinite` : undefined }}>
|
||||
<g
|
||||
style={{
|
||||
animation:
|
||||
def.bodyAnimation && !def.bodyAnimation.includes("flicker")
|
||||
? `${def.bodyAnimation} 2.2s ease-in-out infinite`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Hands preset={def.handPreset} filterId={filterId} />
|
||||
</g>
|
||||
</>
|
||||
|
||||
@ -10,6 +10,13 @@ export {
|
||||
EVOO_PUPIL_SMALL_R,
|
||||
} from "./brand-lock.js";
|
||||
export { POSE_DEFINITIONS } from "./poses.js";
|
||||
export {
|
||||
EVOO_CHARACTER_SPRING,
|
||||
EVOO_MOUTH_LAG_MS,
|
||||
EVOO_POSE_TRANSITION_MS,
|
||||
EVOO_POSE_TRANSITION_S,
|
||||
evooMouthLagMs,
|
||||
} from "./motion-tokens.js";
|
||||
export {
|
||||
mapBotStatusToPose,
|
||||
mapCollaborationToPose,
|
||||
|
||||
19
packages/ui-web/src/evoo-character/motion-tokens.ts
Normal file
19
packages/ui-web/src/evoo-character/motion-tokens.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/** Character-only motion — see MOTION-EVOO-CHARACTER.md */
|
||||
|
||||
/** Eyes lead mouth on pose change (ms). */
|
||||
export const EVOO_MOUTH_LAG_MS = { min: 40, max: 80 } as const;
|
||||
|
||||
/** Full pose transition duration (ms). */
|
||||
export const EVOO_POSE_TRANSITION_MS = { min: 220, max: 320 } as const;
|
||||
|
||||
/** Midpoint transition used in SVG/CSS. */
|
||||
export const EVOO_POSE_TRANSITION_S = (EVOO_POSE_TRANSITION_MS.min + EVOO_POSE_TRANSITION_MS.max) / 2 / 1000;
|
||||
|
||||
/** Cute / soft / snap — character springs only. */
|
||||
export const EVOO_CHARACTER_SPRING = "cubic-bezier(0.34, 1.25, 0.64, 1)";
|
||||
|
||||
export function evooMouthLagMs(seed = Math.random()): number {
|
||||
return Math.round(
|
||||
EVOO_MOUTH_LAG_MS.min + seed * (EVOO_MOUTH_LAG_MS.max - EVOO_MOUTH_LAG_MS.min),
|
||||
);
|
||||
}
|
||||
@ -10,14 +10,14 @@ export const POSE_DEFINITIONS: Record<EvooPose, EvooPoseDefinition> = {
|
||||
mouth: "tiny-o",
|
||||
gaze: { x: 0, y: -0.4 },
|
||||
accessory: "headset",
|
||||
handPreset: "listening",
|
||||
handPreset: "rest",
|
||||
bodyAnimation: "evoo-breathe-awake",
|
||||
aura: "speak",
|
||||
},
|
||||
speaking: {
|
||||
mouth: "speaking",
|
||||
gaze: { x: 0.3, y: 0 },
|
||||
handPreset: "speaking",
|
||||
handPreset: "rest",
|
||||
bodyAnimation: "evoo-conductor-left",
|
||||
aura: "speak",
|
||||
},
|
||||
@ -25,20 +25,20 @@ export const POSE_DEFINITIONS: Record<EvooPose, EvooPoseDefinition> = {
|
||||
mouth: "flat",
|
||||
gaze: { x: 1.2, y: -1.1 },
|
||||
accessory: "thought",
|
||||
handPreset: "thinking",
|
||||
handPreset: "rest",
|
||||
aura: "think",
|
||||
},
|
||||
working: {
|
||||
mouth: "flat",
|
||||
gaze: { x: 0.6, y: 0.8 },
|
||||
handPreset: "typing",
|
||||
handPreset: "rest",
|
||||
bodyAnimation: "evoo-quantum-flicker",
|
||||
},
|
||||
reading: {
|
||||
mouth: "tiny-o",
|
||||
gaze: { x: 1.4, y: 1.2 },
|
||||
accessory: "clipboard",
|
||||
handPreset: "reading",
|
||||
handPreset: "rest",
|
||||
eyeOpen: 0.92,
|
||||
},
|
||||
analyzing: {
|
||||
@ -46,14 +46,14 @@ export const POSE_DEFINITIONS: Record<EvooPose, EvooPoseDefinition> = {
|
||||
gaze: { x: 0.8, y: 0.4 },
|
||||
accessory: "magnifier",
|
||||
browRaise: "left",
|
||||
handPreset: "analyzing",
|
||||
handPreset: "rest",
|
||||
eyeOpen: 0.88,
|
||||
},
|
||||
skeptical: {
|
||||
mouth: "flat",
|
||||
browRaise: "right",
|
||||
gaze: { x: -0.6, y: -0.2 },
|
||||
handPreset: "skeptical",
|
||||
handPreset: "rest",
|
||||
eyeOpen: 0.9,
|
||||
},
|
||||
curious: {
|
||||
@ -74,13 +74,13 @@ export const POSE_DEFINITIONS: Record<EvooPose, EvooPoseDefinition> = {
|
||||
mouth: "concerned",
|
||||
browRaise: "worried",
|
||||
gaze: { x: 0, y: 0.6 },
|
||||
handPreset: "concerned",
|
||||
handPreset: "rest",
|
||||
eyeOpen: 0.94,
|
||||
},
|
||||
waiting: {
|
||||
mouth: "flat",
|
||||
mouth: "tiny-o",
|
||||
gaze: { x: -0.5, y: 0 },
|
||||
handPreset: "waiting",
|
||||
handPreset: "rest",
|
||||
eyeOpen: 0.72,
|
||||
},
|
||||
success: {
|
||||
@ -112,7 +112,7 @@ export const POSE_DEFINITIONS: Record<EvooPose, EvooPoseDefinition> = {
|
||||
collaborating: {
|
||||
mouth: "soft-smile",
|
||||
accessory: "tool",
|
||||
handPreset: "collaborating",
|
||||
handPreset: "rest",
|
||||
gaze: { x: 0.9, y: -0.3 },
|
||||
},
|
||||
onboarding: {
|
||||
@ -125,7 +125,7 @@ export const POSE_DEFINITIONS: Record<EvooPose, EvooPoseDefinition> = {
|
||||
typing: {
|
||||
mouth: "flat",
|
||||
gaze: { x: 0.6, y: 0.8 },
|
||||
handPreset: "typing",
|
||||
handPreset: "rest",
|
||||
bodyAnimation: "evoo-quantum-flicker",
|
||||
},
|
||||
peek: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user