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
|
## 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 |
|
| Cue | Trigger |
|
||||||
|-----|---------|
|
|-----|---------|
|
||||||
| `work-complete` | Run transitions from active → finished (text mode) |
|
| `app-start` | Cold open — mint→violet shimmer |
|
||||||
| `listening-start` / `listening-stop` | Voice phase enters/leaves listening |
|
| `interact` | Primary nav / roster taps |
|
||||||
| `speaking-start` / `speaking-done` | TTS playback lifecycle |
|
| `success-resolve` | Run finished — mint→gold |
|
||||||
| `attention` | Run error surfaced in composer |
|
| `need-you` | `waiting_input` / takeover — with `ouh` vocal |
|
||||||
| `message-sent` | Successful composer send |
|
| `work-complete` | Alias of `success-resolve` |
|
||||||
| `handoff` | Handoff block appears in thread |
|
| `listening-start` / `listening-stop` | Voice phase |
|
||||||
| `group-spark` | Peer Oo reply in a group thread — playful staccato |
|
| `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)
|
## 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 type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||||
import { describe, expect, it } from "vitest";
|
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");
|
const now = new Date("2026-09-22T12:00:00.000Z");
|
||||||
|
|
||||||
@ -82,14 +82,17 @@ describe("isToday", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildHeuteBrief", () => {
|
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({
|
const brief = buildHeuteBrief({
|
||||||
bots: [
|
bots: [
|
||||||
bot({ id: "bot-1", unread: true }),
|
bot({ id: "bot-1", unread: true }),
|
||||||
bot({ id: "bot-2", unread: true, updatedAt: "2026-09-22T08:00:00.000Z", name: "Steve" }),
|
bot({ id: "bot-2", unread: true, updatedAt: "2026-09-22T08:00:00.000Z", name: "Steve" }),
|
||||||
],
|
],
|
||||||
groups: [group({ updatedAt: "2026-09-22T09:30:00.000Z" })],
|
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: [
|
recentRuns: [
|
||||||
run({
|
run({
|
||||||
runId: "run-2",
|
runId: "run-2",
|
||||||
@ -98,23 +101,28 @@ describe("buildHeuteBrief", () => {
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
trigger: "routine",
|
trigger: "routine",
|
||||||
}),
|
}),
|
||||||
|
run({
|
||||||
|
runId: "run-4",
|
||||||
|
botId: "bot-5",
|
||||||
|
botName: "DoneBot",
|
||||||
|
status: "completed",
|
||||||
|
updatedAt: "2026-09-22T09:00:00.000Z",
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(brief.actionCount).toBe(3);
|
expect(brief.actionCount).toBe(3);
|
||||||
expect(brief.jetzt.map((item) => item.kind)).toEqual(["run", "run", "unread"]);
|
expect(brief.needsYou.map((item) => item.kind)).toEqual(["run", "run", "unread"]);
|
||||||
expect(brief.weiter).toHaveLength(1);
|
expect(brief.doing).toHaveLength(1);
|
||||||
expect(brief.weiter[0]?.chat.id).toBe("group-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", () => {
|
it("caps needs-you at three items", () => {
|
||||||
const bots = Array.from({ length: 7 }, (_, index) =>
|
const bots = Array.from({ length: 5 }, (_, index) =>
|
||||||
bot({
|
bot({ id: `bot-${index}`, unread: true, name: `Bot ${index}` }),
|
||||||
id: `bot-${index}`,
|
|
||||||
name: `Bot ${index}`,
|
|
||||||
updatedAt: `2026-09-22T${String(index).padStart(2, "0")}:00:00.000Z`,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
const brief = buildHeuteBrief({
|
const brief = buildHeuteBrief({
|
||||||
bots,
|
bots,
|
||||||
@ -123,6 +131,7 @@ describe("buildHeuteBrief", () => {
|
|||||||
recentRuns: [],
|
recentRuns: [],
|
||||||
now,
|
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 type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||||
|
import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity";
|
||||||
|
|
||||||
export type JetztRunItem = {
|
export type JetztRunItem = {
|
||||||
kind: "run";
|
kind: "run";
|
||||||
@ -13,17 +14,39 @@ export type JetztUnreadItem = {
|
|||||||
|
|
||||||
export type JetztItem = JetztRunItem | 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 = {
|
export type WeiterItem = {
|
||||||
chatKind: "bot" | "group";
|
chatKind: "bot" | "group";
|
||||||
chat: Bot | Group;
|
chat: Bot | Group;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HeuteBriefData = {
|
export type HeuteBriefData = {
|
||||||
jetzt: JetztItem[];
|
/** Needs you — capped at three visible rows. */
|
||||||
weiter: WeiterItem[];
|
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;
|
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 {
|
export function isToday(iso: string, now = new Date()): boolean {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
if (Number.isNaN(date.getTime())) return false;
|
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}`;
|
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: {
|
export function buildHeuteBrief(params: {
|
||||||
bots: Bot[];
|
bots: Bot[];
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
@ -68,7 +83,7 @@ export function buildHeuteBrief(params: {
|
|||||||
now?: Date;
|
now?: Date;
|
||||||
}): HeuteBriefData {
|
}): HeuteBriefData {
|
||||||
const { bots, groups, activeRuns, recentRuns, now = new Date() } = params;
|
const { bots, groups, activeRuns, recentRuns, now = new Date() } = params;
|
||||||
const jetzt: JetztItem[] = [];
|
const needsYouAll: JetztItem[] = [];
|
||||||
const coveredTargets = new Set<string>();
|
const coveredTargets = new Set<string>();
|
||||||
|
|
||||||
const actionableRuns = [
|
const actionableRuns = [
|
||||||
@ -79,43 +94,46 @@ export function buildHeuteBrief(params: {
|
|||||||
for (const run of actionableRuns) {
|
for (const run of actionableRuns) {
|
||||||
if (seenRunIds.has(run.runId)) continue;
|
if (seenRunIds.has(run.runId)) continue;
|
||||||
seenRunIds.add(run.runId);
|
seenRunIds.add(run.runId);
|
||||||
jetzt.push({ kind: "run", run });
|
needsYouAll.push({ kind: "run", run });
|
||||||
coveredTargets.add(runTargetKey(run));
|
coveredTargets.add(runTargetKey(run));
|
||||||
}
|
}
|
||||||
|
|
||||||
const unreadChats: JetztUnreadItem[] = [
|
const unreadChats: JetztUnreadItem[] = [
|
||||||
...bots.filter((bot) => bot.unread).map((bot) => ({ kind: "unread" as const, chatKind: "bot" as const, chat: bot })),
|
...bots
|
||||||
...groups.filter((group) => group.unread).map((group) => ({
|
.filter((bot) => bot.unread)
|
||||||
kind: "unread" as const,
|
.map((bot) => ({ kind: "unread" as const, chatKind: "bot" as const, chat: bot })),
|
||||||
chatKind: "group" as const,
|
...groups
|
||||||
chat: group,
|
.filter((group) => group.unread)
|
||||||
})),
|
.map((group) => ({
|
||||||
|
kind: "unread" as const,
|
||||||
|
chatKind: "group" as const,
|
||||||
|
chat: group,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
for (const item of unreadChats) {
|
for (const item of unreadChats) {
|
||||||
const key = chatTargetKey(item.chatKind, item.chat.id);
|
const key = chatTargetKey(item.chatKind, item.chat.id);
|
||||||
if (coveredTargets.has(key)) continue;
|
if (coveredTargets.has(key)) continue;
|
||||||
jetzt.push(item);
|
needsYouAll.push(item);
|
||||||
coveredTargets.add(key);
|
coveredTargets.add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
const weiterCandidates: WeiterItem[] = [
|
const doing: DoingItem[] = activeRuns
|
||||||
...bots
|
.filter((run) => isDoingStatus(run.status))
|
||||||
.filter((bot) => isToday(bot.updatedAt, now))
|
.map((run) => ({ kind: "run" as const, run }));
|
||||||
.map((bot) => ({ chatKind: "bot" as const, chat: bot })),
|
|
||||||
...groups
|
const done: DoneItem[] = recentRuns
|
||||||
.filter((group) => isToday(group.updatedAt, now))
|
.filter((run) => run.status === "completed" && isToday(run.updatedAt, now))
|
||||||
.map((group) => ({ chatKind: "group" as const, chat: group })),
|
.slice(0, 5)
|
||||||
];
|
.map((run) => ({ kind: "run" as const, run }));
|
||||||
weiterCandidates.sort(
|
|
||||||
(a, b) => new Date(b.chat.updatedAt).getTime() - new Date(a.chat.updatedAt).getTime(),
|
const needsYou = needsYouAll.slice(0, HEUTE_NEEDS_YOU_CAP);
|
||||||
);
|
|
||||||
const weiter = weiterCandidates
|
|
||||||
.filter((item) => !coveredTargets.has(chatTargetKey(item.chatKind, item.chat.id)))
|
|
||||||
.slice(0, 5);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jetzt,
|
needsYou,
|
||||||
weiter,
|
doing,
|
||||||
actionCount: jetzt.length,
|
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", () => {
|
it("plays procedural cues when enabled", () => {
|
||||||
const cues: OoSoundCue[] = [
|
const cues: OoSoundCue[] = [
|
||||||
|
"app-start",
|
||||||
|
"interact",
|
||||||
|
"success-resolve",
|
||||||
|
"need-you",
|
||||||
"work-complete",
|
"work-complete",
|
||||||
"listening-start",
|
"listening-start",
|
||||||
"listening-stop",
|
"listening-stop",
|
||||||
@ -87,6 +91,7 @@ describe("ooSound", () => {
|
|||||||
"attention",
|
"attention",
|
||||||
"message-sent",
|
"message-sent",
|
||||||
"handoff",
|
"handoff",
|
||||||
|
"group-spark",
|
||||||
];
|
];
|
||||||
for (const cue of cues) {
|
for (const cue of cues) {
|
||||||
expect(() => ooSound.play(cue)).not.toThrow();
|
expect(() => ooSound.play(cue)).not.toThrow();
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
|
import { ooAudioBus } from "./oo-audio-bus";
|
||||||
import { readOoSoundEnabled, writeOoSoundEnabled } from "./oo-sound-pref";
|
import { readOoSoundEnabled, writeOoSoundEnabled } from "./oo-sound-pref";
|
||||||
|
|
||||||
export type OoSoundCue =
|
export type OoSoundCue =
|
||||||
|
| "app-start"
|
||||||
|
| "interact"
|
||||||
|
| "success-resolve"
|
||||||
|
| "need-you"
|
||||||
| "work-complete"
|
| "work-complete"
|
||||||
| "listening-start"
|
| "listening-start"
|
||||||
| "listening-stop"
|
| "listening-stop"
|
||||||
@ -11,15 +16,6 @@ export type OoSoundCue =
|
|||||||
| "handoff"
|
| "handoff"
|
||||||
| "group-spark";
|
| "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 {
|
function prefersReducedMotion(): boolean {
|
||||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
@ -54,17 +50,57 @@ function tone(
|
|||||||
osc.stop(startAt + duration);
|
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) {
|
function playCueOnContext(ctx: AudioContext, cue: OoSoundCue) {
|
||||||
const master = ctx.createGain();
|
const master = ooAudioBus.sfxDestination(ctx);
|
||||||
master.gain.value = 0.85;
|
|
||||||
master.connect(ctx.destination);
|
|
||||||
const t0 = ctx.currentTime + 0.01;
|
const t0 = ctx.currentTime + 0.01;
|
||||||
|
|
||||||
switch (cue) {
|
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":
|
case "work-complete":
|
||||||
tone(ctx, master, 392, t0, 0.42, { gain: 0.07 });
|
// mint → gold resolve (~270 ms)
|
||||||
tone(ctx, master, 523.25, t0 + 0.09, 0.48, { gain: 0.06 });
|
sweep(ctx, master, 523.25, 659.25, t0, 0.12, { gain: 0.042, type: "sine" });
|
||||||
tone(ctx, master, 659.25, t0 + 0.18, 0.62, { gain: 0.05, type: "triangle" });
|
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;
|
break;
|
||||||
case "listening-start":
|
case "listening-start":
|
||||||
tone(ctx, master, 220, t0, 0.22, { gain: 0.045, type: "triangle" });
|
tone(ctx, master, 220, t0, 0.22, { gain: 0.045, type: "triangle" });
|
||||||
@ -102,7 +138,6 @@ function playCueOnContext(ctx: AudioContext, cue: OoSoundCue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class OoSoundEngine {
|
class OoSoundEngine {
|
||||||
private ctx: AudioContext | null = null;
|
|
||||||
private enabled = readOoSoundEnabled();
|
private enabled = readOoSoundEnabled();
|
||||||
|
|
||||||
isEnabled(): boolean {
|
isEnabled(): boolean {
|
||||||
@ -114,24 +149,12 @@ class OoSoundEngine {
|
|||||||
writeOoSoundEnabled(next);
|
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) {
|
play(cue: OoSoundCue) {
|
||||||
if (!this.enabled) return;
|
if (!this.enabled) return;
|
||||||
if (prefersReducedMotion()) return;
|
if (prefersReducedMotion()) return;
|
||||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||||
|
|
||||||
const ctx = this.ensureContext();
|
const ctx = ooAudioBus.ensureContext();
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const run = () => {
|
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 { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { RunActivityRow } from "@rakazo/contracts";
|
import type { RunActivityRow } from "@rakazo/contracts";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { isNeedsYouStatus, runActivityLabel } from "../lib/agent-activity";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
function statusTone(status: RunActivityRow["status"]): string {
|
function statusTone(status: RunActivityRow["status"]): string {
|
||||||
if (status === "failed") return "text-destructive";
|
if (status === "failed") return "text-destructive";
|
||||||
if (status === "cancelled") return "text-muted-foreground";
|
if (status === "cancelled") return "text-muted-foreground";
|
||||||
if (status === "completed") return "text-success";
|
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";
|
return "text-foreground";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,7 +121,13 @@ function ActivityRow({ run, onOpen }: { run: RunActivityRow; onOpen: () => void
|
|||||||
{run.promptSnippet}
|
{run.promptSnippet}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -142,24 +149,6 @@ function formatRelativeTime(iso: string, now = new Date()): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: RunActivityRow["status"]): string {
|
function statusLabel(status: RunActivityRow["status"]): string {
|
||||||
switch (status) {
|
if (isNeedsYouStatus(status)) return t`Needs you`;
|
||||||
case "queued":
|
return runActivityLabel(status);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
import { i18n } from "@lingui/core";
|
import { i18n } from "@lingui/core";
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||||
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||||
import { botColors } from "@rakazo/ui-tokens";
|
import { botColors } from "@rakazo/ui-tokens";
|
||||||
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
|
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { runActivityLabel } from "../lib/agent-activity";
|
||||||
import {
|
import {
|
||||||
buildHeuteBrief,
|
buildHeuteBrief,
|
||||||
cleanBriefPreview,
|
cleanBriefPreview,
|
||||||
|
type DoingItem,
|
||||||
|
type DoneItem,
|
||||||
type JetztItem,
|
type JetztItem,
|
||||||
type WeiterItem,
|
|
||||||
} from "../lib/heute-brief";
|
} from "../lib/heute-brief";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
@ -33,7 +34,6 @@ export function HeuteBrief({
|
|||||||
onOpenRun,
|
onOpenRun,
|
||||||
onStartChat,
|
onStartChat,
|
||||||
}: HeuteBriefProps) {
|
}: HeuteBriefProps) {
|
||||||
const { t } = useLingui();
|
|
||||||
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
|
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
|
||||||
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
|
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -79,42 +79,41 @@ export function HeuteBrief({
|
|||||||
[activeRuns, bots, groups, recentRuns],
|
[activeRuns, bots, groups, recentRuns],
|
||||||
);
|
);
|
||||||
|
|
||||||
const botColorById = useMemo(
|
const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]);
|
||||||
() => new Map(bots.map((bot) => [bot.id, bot.color])),
|
|
||||||
[bots],
|
|
||||||
);
|
|
||||||
|
|
||||||
const dateLabel = formatHeuteDate(new Date());
|
const dateLabel = formatHeuteDate(new Date());
|
||||||
|
const hasContent = brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief">
|
||||||
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">
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
|
<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">
|
<p className="text-ui-body text-muted-foreground">
|
||||||
<Trans>Loading…</Trans>
|
<Trans>Loading…</Trans>
|
||||||
</p>
|
</p>
|
||||||
) : brief.jetzt.length === 0 && brief.weiter.length === 0 ? (
|
) : !hasContent ? (
|
||||||
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
|
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
|
||||||
) : (
|
) : (
|
||||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
|
||||||
{brief.jetzt.length > 0 ? (
|
{brief.needsYou.length > 0 ? (
|
||||||
<section data-testid="heute-jetzt">
|
<section data-testid="heute-needs-you">
|
||||||
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||||
<Trans>Now</Trans>
|
<Trans>Needs you</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{brief.jetzt.map((item) => (
|
{brief.needsYou.map((item) => (
|
||||||
<JetztRow
|
<NeedsYouRow
|
||||||
key={jetztRowKey(item)}
|
key={jetztRowKey(item)}
|
||||||
item={item}
|
item={item}
|
||||||
botColorById={botColorById}
|
botColorById={botColorById}
|
||||||
@ -124,21 +123,48 @@ export function HeuteBrief({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{brief.weiter.length > 0 ? (
|
{brief.doing.length > 0 ? (
|
||||||
<section data-testid="heute-weiter">
|
<section data-testid="heute-doing">
|
||||||
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||||
<Trans>Next</Trans>
|
<Trans>Doing</Trans>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{brief.weiter.map((item) => (
|
{brief.doing.map((item) => (
|
||||||
<WeiterRow
|
<DoingRow
|
||||||
key={`${item.chatKind}:${item.chat.id}`}
|
key={`doing:${item.run.runId}`}
|
||||||
item={item}
|
item={item}
|
||||||
onOpenBot={onOpenBot}
|
botColorById={botColorById}
|
||||||
onOpenGroup={onOpenGroup}
|
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>
|
</div>
|
||||||
@ -164,20 +190,25 @@ export function HeuteBrief({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusLine({ actionCount, loading }: { actionCount: number; loading: boolean }) {
|
function StatusLine({
|
||||||
if (loading && actionCount === 0) {
|
actionCount,
|
||||||
|
doingCount,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
actionCount: number;
|
||||||
|
doingCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
if (loading && actionCount === 0 && doingCount === 0) {
|
||||||
return <Trans>Loading…</Trans>;
|
return <Trans>Loading…</Trans>;
|
||||||
}
|
}
|
||||||
if (actionCount === 0) {
|
if (actionCount > 0) {
|
||||||
return <Trans>All quiet</Trans>;
|
return <Plural value={actionCount} one="# thing needs you" other="# things need you" />;
|
||||||
}
|
}
|
||||||
return (
|
if (doingCount > 0) {
|
||||||
<Plural
|
return <Plural value={doingCount} one="# Oo is working" other="# Oos are working" />;
|
||||||
value={actionCount}
|
}
|
||||||
one="# thing needs you"
|
return <Trans>All quiet</Trans>;
|
||||||
other="# things need you"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots: boolean }) {
|
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,
|
item,
|
||||||
botColorById,
|
botColorById,
|
||||||
onOpenBot,
|
onOpenBot,
|
||||||
@ -220,13 +259,13 @@ function JetztRow({
|
|||||||
if (item.kind === "run") {
|
if (item.kind === "run") {
|
||||||
const run = item.run;
|
const run = item.run;
|
||||||
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
|
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
|
||||||
const subtitle = run.promptSnippet || runStatusLabel(run.status);
|
const subtitle = run.promptSnippet || runActivityLabel(run.status);
|
||||||
const label = t`${title}, ${runStatusLabel(run.status)}`;
|
const label = t`${title}, ${runActivityLabel(run.status)}`;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
data-testid={`heute-jetzt-run-${run.runId}`}
|
data-testid={`heute-needs-you-run-${run.runId}`}
|
||||||
onClick={() => onOpenRun(run)}
|
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"
|
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]}
|
color={botColorById.get(run.botId) ?? botColors[3]}
|
||||||
identity={run.botId}
|
identity={run.botId}
|
||||||
size={40}
|
size={40}
|
||||||
|
status={run.status}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-ui-body font-semibold text-foreground">{title}</div>
|
<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 className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{subtitle}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-ui-label font-medium text-warning">
|
<ActionableChip label={t`Needs you`} />
|
||||||
{runStatusLabel(run.status)}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -255,11 +293,9 @@ function JetztRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
data-testid={`heute-jetzt-unread-${chatKind}-${chat.id}`}
|
data-testid={`heute-needs-you-unread-${chatKind}-${chat.id}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
chatKind === "bot"
|
chatKind === "bot" ? onOpenBot(chat.id, chat.spaceId) : onOpenGroup(chat.id, chat.spaceId)
|
||||||
? 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"
|
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="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 className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{preview}</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="inline-block size-2 shrink-0 rounded-full bg-primary" aria-hidden="true" />
|
<ActionableChip label={t`Unread`} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WeiterRow({
|
function DoingRow({
|
||||||
item,
|
item,
|
||||||
onOpenBot,
|
botColorById,
|
||||||
onOpenGroup,
|
onOpenRun,
|
||||||
}: {
|
}: {
|
||||||
item: WeiterItem;
|
item: DoingItem;
|
||||||
onOpenBot: (botId: string, spaceId: string) => void;
|
botColorById: Map<string, string>;
|
||||||
onOpenGroup: (groupId: string, spaceId: string) => void;
|
onOpenRun: (run: RunActivityRow) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { chatKind, chat } = item;
|
const run = item.run;
|
||||||
const title = chat.name;
|
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
|
||||||
const preview =
|
const subtitle = run.promptSnippet || runActivityLabel(run.status);
|
||||||
cleanBriefPreview(chat.preview) ||
|
const label = t`${title}, ${runActivityLabel(run.status)}`;
|
||||||
(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}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
data-testid={`heute-weiter-${chatKind}-${chat.id}`}
|
data-testid={`heute-doing-run-${run.runId}`}
|
||||||
onClick={() =>
|
onClick={() => onOpenRun(run)}
|
||||||
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"
|
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
|
||||||
<BotAvatar
|
color={botColorById.get(run.botId) ?? botColors[3]}
|
||||||
color={(chat as Bot).color}
|
identity={run.botId}
|
||||||
identity={chat.id}
|
size={40}
|
||||||
size={40}
|
status={run.status}
|
||||||
status={(chat as Bot).status}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<GroupAvatar members={(chat as Group).members} size={40} />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="truncate text-ui-body font-medium text-foreground">{title}</div>
|
||||||
<span className="truncate text-ui-body font-medium text-foreground">{title}</span>
|
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{subtitle}</div>
|
||||||
<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>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -342,19 +400,6 @@ function jetztRowKey(item: JetztItem): string {
|
|||||||
return `unread:${item.chatKind}:${item.chat.id}`;
|
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 {
|
function formatHeuteDate(date: Date): string {
|
||||||
const locale = i18n.locale || "de";
|
const locale = i18n.locale || "de";
|
||||||
return date.toLocaleDateString(locale, {
|
return date.toLocaleDateString(locale, {
|
||||||
@ -363,10 +408,3 @@ function formatHeuteDate(date: Date): string {
|
|||||||
month: "long",
|
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 { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome";
|
||||||
import { useSidebarResize } from "../hooks/useSidebarResize";
|
import { useSidebarResize } from "../hooks/useSidebarResize";
|
||||||
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
|
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
|
||||||
|
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
|
||||||
import type { ArtifactTarget } from "../lib/artifact-open";
|
import type { ArtifactTarget } from "../lib/artifact-open";
|
||||||
import { authClient } from "../lib/auth";
|
import { authClient } from "../lib/auth";
|
||||||
import { takeInitialBootstrap } from "../lib/bootstrap";
|
import { takeInitialBootstrap } from "../lib/bootstrap";
|
||||||
@ -178,10 +179,14 @@ import {
|
|||||||
resolveActiveGroupSpeaker,
|
resolveActiveGroupSpeaker,
|
||||||
resolveWorkingGroupBots,
|
resolveWorkingGroupBots,
|
||||||
} from "../lib/group-stage";
|
} from "../lib/group-stage";
|
||||||
|
import { buildHeuteBrief } from "../lib/heute-brief";
|
||||||
import { localTimezone } from "../lib/local-timezone";
|
import { localTimezone } from "../lib/local-timezone";
|
||||||
import { copyableMessageText } from "../lib/message-text";
|
import { copyableMessageText } from "../lib/message-text";
|
||||||
import { messageProviderLabel } from "../lib/messaging";
|
import { messageProviderLabel } from "../lib/messaging";
|
||||||
|
import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire";
|
||||||
import { ooSound } from "../lib/oo-sound";
|
import { ooSound } from "../lib/oo-sound";
|
||||||
|
import { ooVocals } from "../lib/oo-vocals";
|
||||||
|
import { ooVoiceMark } from "../lib/oo-voice-mark";
|
||||||
import {
|
import {
|
||||||
isFileDrag,
|
isFileDrag,
|
||||||
isFilePaste,
|
isFilePaste,
|
||||||
@ -216,9 +221,9 @@ import {
|
|||||||
} from "../lib/transcript-scroll";
|
} from "../lib/transcript-scroll";
|
||||||
import { speaker } from "../lib/tts";
|
import { speaker } from "../lib/tts";
|
||||||
import { ActivityList } from "./ActivityList";
|
import { ActivityList } from "./ActivityList";
|
||||||
import { HeuteBrief } from "./HeuteBrief";
|
|
||||||
import type { ContextMenuPosition } from "./BotContextMenu";
|
import type { ContextMenuPosition } from "./BotContextMenu";
|
||||||
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
|
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
|
||||||
|
import { HeuteBrief } from "./HeuteBrief";
|
||||||
import { HostComputerPrompt } from "./HostComputerPrompt";
|
import { HostComputerPrompt } from "./HostComputerPrompt";
|
||||||
import { ProjectSessionTimeline } from "./ProjectSessionTimeline";
|
import { ProjectSessionTimeline } from "./ProjectSessionTimeline";
|
||||||
import {
|
import {
|
||||||
@ -366,7 +371,6 @@ export function ShellPage() {
|
|||||||
return "live";
|
return "live";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const toggleTradingMode = useCallback(() => {
|
const toggleTradingMode = useCallback(() => {
|
||||||
setTradingMode((prev) => {
|
setTradingMode((prev) => {
|
||||||
const next = prev === "live" ? "paper" : "live";
|
const next = prev === "live" ? "paper" : "live";
|
||||||
@ -414,6 +418,7 @@ export function ShellPage() {
|
|||||||
startResizing: startResizingRight,
|
startResizing: startResizingRight,
|
||||||
} = useSidebarResize(384, "right", 280, 500);
|
} = useSidebarResize(384, "right", 280, 500);
|
||||||
|
|
||||||
|
const [heuteActionCount, setHeuteActionCount] = useState(0);
|
||||||
const [archivedOpen, setArchivedOpen] = useState(false);
|
const [archivedOpen, setArchivedOpen] = useState(false);
|
||||||
const [expandedTeamGroups, setExpandedTeamGroups] = useState<Record<string, boolean>>({});
|
const [expandedTeamGroups, setExpandedTeamGroups] = useState<Record<string, boolean>>({});
|
||||||
const [collapsedSidebarSections, setCollapsedSidebarSections] = useState(() => new Set<string>());
|
const [collapsedSidebarSections, setCollapsedSidebarSections] = useState(() => new Set<string>());
|
||||||
@ -734,10 +739,7 @@ export function ShellPage() {
|
|||||||
? botSections.find((section) => section.id === activeGroup.sectionId)
|
? botSections.find((section) => section.id === activeGroup.sectionId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const showProjectTimeline =
|
const showProjectTimeline =
|
||||||
inGroup &&
|
inGroup && activeNavPillar !== "heute" && !projectSessionKey && Boolean(activeGroup?.sectionId);
|
||||||
activeNavPillar !== "heute" &&
|
|
||||||
!projectSessionKey &&
|
|
||||||
Boolean(activeGroup?.sectionId);
|
|
||||||
const activePendingAttachments = useMemo(
|
const activePendingAttachments = useMemo(
|
||||||
() => attachmentsForThread(pendingAttachments, inGroup ? groupId : active?.id),
|
() => attachmentsForThread(pendingAttachments, inGroup ? groupId : active?.id),
|
||||||
[active?.id, groupId, inGroup, pendingAttachments],
|
[active?.id, groupId, inGroup, pendingAttachments],
|
||||||
@ -1351,6 +1353,10 @@ export function ShellPage() {
|
|||||||
}
|
}
|
||||||
if (event.payload.role === "bot") markBotReadIfVisible(active.id);
|
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 (
|
if (
|
||||||
isRunTerminalEvent(event) ||
|
isRunTerminalEvent(event) ||
|
||||||
event.type === "run.waiting_input" ||
|
event.type === "run.waiting_input" ||
|
||||||
@ -1444,6 +1450,10 @@ export function ShellPage() {
|
|||||||
void refreshBots().catch(() => undefined);
|
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") {
|
if (isRunTerminalEvent(event) || event.type === "run.waiting_input") {
|
||||||
// waiting_input: reconcile ask cards if a stale post-send refresh raced SSE.
|
// waiting_input: reconcile ask cards if a stale post-send refresh raced SSE.
|
||||||
void refreshGroupThread(groupId).catch(() => undefined);
|
void refreshGroupThread(groupId).catch(() => undefined);
|
||||||
@ -1567,7 +1577,10 @@ export function ShellPage() {
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
entries.push({
|
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,
|
title: space.name,
|
||||||
bots: groupsInSpace,
|
bots: groupsInSpace,
|
||||||
sectionId: null,
|
sectionId: null,
|
||||||
@ -1958,10 +1971,70 @@ export function ShellPage() {
|
|||||||
const prevTranscriptRunning = useRef(false);
|
const prevTranscriptRunning = useRef(false);
|
||||||
const prevRunErrorId = useRef<string | null>(null);
|
const prevRunErrorId = useRef<string | null>(null);
|
||||||
const seenHandoffKeys = useRef(new Set<string>());
|
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(() => {
|
useEffect(() => {
|
||||||
if (prevTranscriptRunning.current && !transcriptRunning && !callOpen) {
|
if (prevTranscriptRunning.current && !transcriptRunning && !callOpen) {
|
||||||
ooSound.play("work-complete");
|
ooSound.play("success-resolve");
|
||||||
|
ooVocals.reactForEvent("success");
|
||||||
}
|
}
|
||||||
prevTranscriptRunning.current = transcriptRunning;
|
prevTranscriptRunning.current = transcriptRunning;
|
||||||
}, [transcriptRunning, callOpen]);
|
}, [transcriptRunning, callOpen]);
|
||||||
@ -3145,7 +3218,11 @@ export function ShellPage() {
|
|||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={
|
||||||
|
heuteActionCount > 0 ? t`Heute, ${heuteActionCount} need you` : t`Heute`
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
ooSound.play("interact");
|
||||||
setActiveNavPillar("heute");
|
setActiveNavPillar("heute");
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
@ -3155,8 +3232,16 @@ export function ShellPage() {
|
|||||||
: "hover:bg-white/[0.04]"
|
: "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" />
|
<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>
|
||||||
<div className="font-semibold text-foreground/90 tracking-tight text-ui-body">
|
<div className="font-semibold text-foreground/90 tracking-tight text-ui-body">
|
||||||
Heute
|
Heute
|
||||||
@ -3214,304 +3299,302 @@ export function ShellPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{conversationSidebarGroups.map((group) => {
|
{conversationSidebarGroups.map((group) => {
|
||||||
const collapsed =
|
const collapsed = Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
||||||
Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
const groupBotIds = group.bots.flatMap((item) =>
|
||||||
const groupBotIds = group.bots.flatMap((item) =>
|
item.kind === "bot" ? [item.chat.id] : [],
|
||||||
item.kind === "bot" ? [item.chat.id] : [],
|
);
|
||||||
);
|
return (
|
||||||
return (
|
<div key={group.key} data-sidebar-group={group.key}>
|
||||||
<div key={group.key} data-sidebar-group={group.key}>
|
{group.title ? (
|
||||||
{group.title ? (
|
<div className="flex items-center pt-3 pb-0.5">
|
||||||
<div className="flex items-center pt-3 pb-0.5">
|
<button
|
||||||
<button
|
type="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"
|
||||||
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={() => {
|
||||||
onClick={() => {
|
if (group.emptySpaceId) {
|
||||||
if (group.emptySpaceId) {
|
openSpaceChat(group.emptySpaceId, "/onboarding");
|
||||||
openSpaceChat(group.emptySpaceId, "/onboarding");
|
return;
|
||||||
return;
|
}
|
||||||
}
|
toggleSidebarSection(group.key);
|
||||||
toggleSidebarSection(group.key);
|
}}
|
||||||
}}
|
onContextMenu={
|
||||||
onContextMenu={
|
group.sectionId
|
||||||
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) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// Prefer section rename over delete-space when both apply;
|
spaceMenuAnchor.current = event.currentTarget;
|
||||||
// the dedicated space-actions button still opens the space menu.
|
setSpaceMenu({
|
||||||
const sections =
|
id: group.spaceId,
|
||||||
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 },
|
position: { x: event.clientX, y: event.clientY },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
: group.canDeleteSpace
|
: undefined
|
||||||
? (event) => {
|
}
|
||||||
event.preventDefault();
|
aria-expanded={group.emptySpaceId ? undefined : !collapsed}
|
||||||
spaceMenuAnchor.current = event.currentTarget;
|
aria-label={
|
||||||
setSpaceMenu({
|
group.emptySpaceId
|
||||||
id: group.spaceId,
|
? t`Open ${group.title}`
|
||||||
position: { x: event.clientX, y: event.clientY },
|
: collapsed
|
||||||
});
|
? t`Expand ${group.title}`
|
||||||
}
|
: t`Collapse ${group.title}`
|
||||||
: 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],
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
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]
|
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
||||||
? "Weniger anzeigen"
|
{group.showLock ? (
|
||||||
: `Alle ${group.bots.length} anzeigen...`}
|
<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>
|
</button>
|
||||||
)}
|
{group.canDeleteSpace ? (
|
||||||
</div>
|
<Button
|
||||||
);
|
variant="ghost"
|
||||||
})}
|
size="icon-sm"
|
||||||
<div className="sidebar-projekte">
|
aria-label={t`Actions for ${group.spaceName}`}
|
||||||
{/* PROJEKTE SECTION */}
|
onClick={(event) => {
|
||||||
<div className="flex items-center justify-between pt-4 pb-1 mt-2 border-t border-white/5 px-2.5">
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
<div>
|
spaceMenuAnchor.current = event.currentTarget;
|
||||||
<div className="rk-shell-section-label">{t`Projekte`}</div>
|
setSpaceMenu({
|
||||||
<p className="mt-0.5 px-0.5 text-ui-caption text-muted-foreground/70">
|
id: group.spaceId,
|
||||||
{t`Threads by topic · folders`}
|
position: { x: rect.left, y: rect.bottom },
|
||||||
</p>
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
<div className="flex items-center gap-1.5">
|
{projectSidebarGroups.length === 0 ? (
|
||||||
<button
|
<div className="p-4 text-center text-ui-label text-muted-foreground">
|
||||||
type="button"
|
<p>{t`No projects yet.`}</p>
|
||||||
aria-label={t`Create a project`}
|
<Button
|
||||||
onClick={() => openNewSpaceDialog("project")}
|
variant="outline"
|
||||||
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
|
size="sm"
|
||||||
>
|
className="mt-2 text-ui-label"
|
||||||
<Plus strokeWidth={2.5} className="size-icon-md" />
|
onClick={() => openNewSpaceDialog("project")}
|
||||||
</button>
|
>
|
||||||
</div>
|
<Plus className="mr-1 size-icon-sm" />
|
||||||
</div>
|
{t`Create a project`}
|
||||||
{projectSidebarGroups.length === 0 ? (
|
</Button>
|
||||||
<div className="p-4 text-center text-ui-label text-muted-foreground">
|
</div>
|
||||||
<p>{t`No projects yet.`}</p>
|
) : null}
|
||||||
<Button
|
{projectSidebarGroups.map((group) => {
|
||||||
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 =
|
const collapsed =
|
||||||
Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
Boolean(group.title) && collapsedSidebarSections.has(group.key);
|
||||||
return (
|
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"
|
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={() => {
|
onClick={() => {
|
||||||
if (group.emptySpaceId) {
|
if (group.emptySpaceId) {
|
||||||
const space = spaces.find((item) => item.id === group.emptySpaceId);
|
const space = spaces.find(
|
||||||
|
(item) => item.id === group.emptySpaceId,
|
||||||
|
);
|
||||||
openSpaceChat(
|
openSpaceChat(
|
||||||
group.emptySpaceId,
|
group.emptySpaceId,
|
||||||
space?.hasContent ? "/app" : "/onboarding",
|
space?.hasContent ? "/app" : "/onboarding",
|
||||||
@ -3679,7 +3764,7 @@ export function ShellPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -4026,11 +4111,7 @@ export function ShellPage() {
|
|||||||
/>
|
/>
|
||||||
) : inGroup ? (
|
) : inGroup ? (
|
||||||
<GroupAvatar
|
<GroupAvatar
|
||||||
members={
|
members={activeGroup?.members ?? activeSnapshot?.members ?? []}
|
||||||
activeGroup?.members ??
|
|
||||||
activeSnapshot?.members ??
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
size={36}
|
size={36}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
@ -4100,7 +4181,6 @@ export function ShellPage() {
|
|||||||
onStartSession={openProjectChat}
|
onStartSession={openProjectChat}
|
||||||
onOpenGroupChat={openProjectGroupChat}
|
onOpenGroupChat={openProjectGroupChat}
|
||||||
onStartVoiceFirst={(goalGroupId) => openProjectVoiceFirst(goalGroupId)}
|
onStartVoiceFirst={(goalGroupId) => openProjectVoiceFirst(goalGroupId)}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -4907,9 +4987,7 @@ export function ShellPage() {
|
|||||||
await refreshBots();
|
await refreshBots();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.assign(
|
window.location.assign(newSpaceDialogVariant === "project" ? "/app" : "/onboarding");
|
||||||
newSpaceDialogVariant === "project" ? "/app" : "/onboarding",
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@ -5410,7 +5488,6 @@ const Transcript = memo(function Transcript({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# evOo Character — Poses & Runtime Mapping
|
# 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)
|
## Brand lock (non-negotiable)
|
||||||
|
|
||||||
> **Oo trademark = asymmetric Kulleraugen + size difference**
|
> **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 { memo, useEffect, useId, useRef, useState } from "react";
|
||||||
import { EvooCharacterSvg } from "./evoo-character/EvooCharacterSvg.js";
|
import { EvooCharacterSvg } from "./evoo-character/EvooCharacterSvg.js";
|
||||||
|
import { evooMouthLagMs } from "./evoo-character/motion-tokens.js";
|
||||||
import { normalizeEvooPose } from "./evoo-character/pose-map.js";
|
import { normalizeEvooPose } from "./evoo-character/pose-map.js";
|
||||||
import type { EvooPose } from "./evoo-character/types.js";
|
import type { EvooPose } from "./evoo-character/types.js";
|
||||||
import { cn } from "./lib/utils.js";
|
import { cn } from "./lib/utils.js";
|
||||||
|
|
||||||
export type { EvooPose } from "./evoo-character/types.js";
|
|
||||||
export {
|
export {
|
||||||
|
EVOO_OO_TRADEMARK,
|
||||||
mapBotStatusToPose,
|
mapBotStatusToPose,
|
||||||
mapCollaborationToPose,
|
mapCollaborationToPose,
|
||||||
mapOnboardingToPose,
|
mapOnboardingToPose,
|
||||||
@ -13,11 +14,11 @@ export {
|
|||||||
mapStreamingToPose,
|
mapStreamingToPose,
|
||||||
mapVoicePhaseToPose,
|
mapVoicePhaseToPose,
|
||||||
mapWelcomeContextToPose,
|
mapWelcomeContextToPose,
|
||||||
EVOO_OO_TRADEMARK,
|
|
||||||
normalizeEvooPose,
|
normalizeEvooPose,
|
||||||
resolveEvooPose,
|
resolveEvooPose,
|
||||||
type VoicePresencePhase,
|
type VoicePresencePhase,
|
||||||
} from "./evoo-character/index.js";
|
} 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 */
|
/** `none` = calm idle only; `hover` = track while pointer is over mascot; `global` = legacy window tracking */
|
||||||
export type PointerTracking = "none" | "hover" | "global";
|
export type PointerTracking = "none" | "hover" | "global";
|
||||||
@ -74,18 +75,37 @@ export const EvooMascot = memo(function EvooMascot({
|
|||||||
showWordmark = false,
|
showWordmark = false,
|
||||||
}: EvooMascotProps) {
|
}: EvooMascotProps) {
|
||||||
const [activePose, setActivePose] = useState<EvooPose>(initialPose);
|
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 [pupilOffset, setPupilOffset] = useState<Point>({ x: 0, y: 0 });
|
||||||
const [saccadeOffset, setSaccadeOffset] = useState<Point>({ x: 0, y: 0 });
|
const [saccadeOffset, setSaccadeOffset] = useState<Point>({ x: 0, y: 0 });
|
||||||
const [isBlinking, setIsBlinking] = useState(false);
|
const [isBlinking, setIsBlinking] = useState(false);
|
||||||
|
const [isSmallBlinking, setIsSmallBlinking] = useState(false);
|
||||||
const [isWinking, setIsWinking] = useState(false);
|
const [isWinking, setIsWinking] = useState(false);
|
||||||
const [isAsleep, setIsAsleep] = useState(false);
|
const [isAsleep, setIsAsleep] = useState(false);
|
||||||
const [hapticRipple, setHapticRipple] = useState(false);
|
const [hapticRipple, setHapticRipple] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const filterId = useId();
|
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(() => {
|
useEffect(() => {
|
||||||
setActivePose(initialPose);
|
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(() => {
|
useEffect(() => {
|
||||||
let blinkTimeout: ReturnType<typeof setTimeout>;
|
let blinkTimeout: ReturnType<typeof setTimeout>;
|
||||||
@ -93,11 +113,18 @@ export const EvooMascot = memo(function EvooMascot({
|
|||||||
const delay = Math.random() * 4000 + 3000;
|
const delay = Math.random() * 4000 + 3000;
|
||||||
blinkTimeout = setTimeout(() => {
|
blinkTimeout = setTimeout(() => {
|
||||||
if (!isAsleep) {
|
if (!isAsleep) {
|
||||||
setIsBlinking(true);
|
const roll = Math.random();
|
||||||
setTimeout(() => {
|
const mode = roll < 0.55 ? "both" : roll < 0.8 ? "big" : "small";
|
||||||
setIsBlinking(false);
|
setIsBlinking(mode === "both" || mode === "big");
|
||||||
scheduleBlink();
|
setIsSmallBlinking(mode === "both" || mode === "small");
|
||||||
}, 140);
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
setIsBlinking(false);
|
||||||
|
setIsSmallBlinking(false);
|
||||||
|
scheduleBlink();
|
||||||
|
},
|
||||||
|
mode === "small" ? 110 : 140,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
scheduleBlink();
|
scheduleBlink();
|
||||||
}
|
}
|
||||||
@ -215,7 +242,16 @@ export const EvooMascot = memo(function EvooMascot({
|
|||||||
const nextPose: EvooPose =
|
const nextPose: EvooPose =
|
||||||
canonical === "idle" ? "greeting" : canonical === "greeting" ? "success" : "idle";
|
canonical === "idle" ? "greeting" : canonical === "greeting" ? "success" : "idle";
|
||||||
setActivePose(nextPose);
|
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;
|
const finalPupilX = pupilOffset.x + saccadeOffset.x;
|
||||||
@ -274,7 +310,13 @@ export const EvooMascot = memo(function EvooMascot({
|
|||||||
)}
|
)}
|
||||||
style={{ width: size, height: size * 0.88 }}
|
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>
|
<defs>
|
||||||
<linearGradient id={`${filterId}-aura`} x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id={`${filterId}-aura`} x1="0" y1="0" x2="1" y2="1">
|
||||||
<stop offset="0%" stopColor="#00E676" />
|
<stop offset="0%" stopColor="#00E676" />
|
||||||
@ -298,12 +340,15 @@ export const EvooMascot = memo(function EvooMascot({
|
|||||||
<EvooCharacterSvg
|
<EvooCharacterSvg
|
||||||
filterId={filterId}
|
filterId={filterId}
|
||||||
pose={activePose}
|
pose={activePose}
|
||||||
|
mouthPose={mouthPose}
|
||||||
pupilX={finalPupilX}
|
pupilX={finalPupilX}
|
||||||
pupilY={finalPupilY}
|
pupilY={finalPupilY}
|
||||||
isBlinking={isBlinking}
|
isBlinking={isBlinking}
|
||||||
|
isSmallBlinking={isSmallBlinking}
|
||||||
isWinking={isWinking}
|
isWinking={isWinking}
|
||||||
isAsleep={isAsleep}
|
isAsleep={isAsleep}
|
||||||
hapticRipple={hapticRipple}
|
hapticRipple={hapticRipple}
|
||||||
|
reducedMotion={reducedMotion}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,21 +5,32 @@ import {
|
|||||||
EVOO_PUPIL_BIG_R,
|
EVOO_PUPIL_BIG_R,
|
||||||
EVOO_PUPIL_SMALL_R,
|
EVOO_PUPIL_SMALL_R,
|
||||||
} from "./brand-lock.js";
|
} 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 { normalizeEvooPose } from "./pose-map.js";
|
||||||
|
import { POSE_DEFINITIONS } from "./poses.js";
|
||||||
import type { EvooMouthStyle, EvooPose } from "./types.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 {
|
export interface EvooCharacterSvgProps {
|
||||||
filterId: string;
|
filterId: string;
|
||||||
pose: EvooPose;
|
pose: EvooPose;
|
||||||
|
/** Mouth follows pose when omitted; lags eyes by 40–80 ms when set separately. */
|
||||||
|
mouthPose?: EvooPose;
|
||||||
pupilX: number;
|
pupilX: number;
|
||||||
pupilY: number;
|
pupilY: number;
|
||||||
isBlinking: boolean;
|
isBlinking: boolean;
|
||||||
|
isSmallBlinking?: boolean;
|
||||||
isWinking: boolean;
|
isWinking: boolean;
|
||||||
isAsleep: boolean;
|
isAsleep: boolean;
|
||||||
hapticRipple?: boolean;
|
hapticRipple?: boolean;
|
||||||
|
/** Skip motion stagger / speak pulse (reduced-motion). */
|
||||||
|
reducedMotion?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mouthPath(style: EvooMouthStyle, asleep: boolean): string {
|
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 showLeft = raise === "left" || raise === "both" || raise === "worried";
|
||||||
const showRight = raise === "right" || raise === "both" || raise === "worried";
|
const showRight = raise === "right" || raise === "both" || raise === "worried";
|
||||||
const worried = raise === "worried";
|
const worried = raise === "worried";
|
||||||
@ -99,7 +116,16 @@ function Accessory({
|
|||||||
case "clipboard":
|
case "clipboard":
|
||||||
return (
|
return (
|
||||||
<g transform="translate(108, 78) rotate(-12)">
|
<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="-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="-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" />
|
<rect x="-6" y="1" width="8" height="1.5" rx="0.75" fill="#E2E8F0" />
|
||||||
@ -126,7 +152,13 @@ function Accessory({
|
|||||||
return (
|
return (
|
||||||
<g transform="translate(106, 70)">
|
<g transform="translate(106, 70)">
|
||||||
<circle cx="0" cy="0" r="9" fill="none" stroke="#0F172A" strokeWidth="2" opacity="0.7" />
|
<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>
|
</g>
|
||||||
);
|
);
|
||||||
case "thought":
|
case "thought":
|
||||||
@ -218,38 +250,110 @@ function Hands({
|
|||||||
case "typing":
|
case "typing":
|
||||||
return (
|
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
|
||||||
<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" }} />
|
cx="38"
|
||||||
<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" />
|
cy="98"
|
||||||
<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" />
|
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":
|
case "peek":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GlassHand x={36} y={66} w={26} h={13} rotate={-10} filterId={filterId} stroke="rgba(255,255,255,0.85)" />
|
<GlassHand
|
||||||
<GlassHand x={90} y={70} w={22} h={11} rotate={10} filterId={filterId} stroke="rgba(255,255,255,0.85)" />
|
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":
|
case "wave":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GlassHand x={24} y={93} w={20} h={10} rotate={0} filterId={filterId} />
|
<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":
|
case "success":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GlassHand x={32} y={86} w={22} h={11} rotate={18} filterId={filterId} stroke="#10B981" />
|
<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":
|
case "thinking":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GlassHand x={27} y={95} w={19} h={9.5} rotate={0} filterId={filterId} />
|
<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":
|
case "listening":
|
||||||
@ -263,7 +367,16 @@ function Hands({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GlassHand x={22} y={92} w={20} h={10} rotate={8} filterId={filterId} />
|
<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":
|
case "reading":
|
||||||
@ -303,9 +416,34 @@ function Hands({
|
|||||||
case "collaborating":
|
case "collaborating":
|
||||||
return (
|
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
|
||||||
<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" />
|
x={34}
|
||||||
<path d="M58 76 Q66 70 74 76" stroke="#38BDF8" strokeWidth="1.5" fill="none" opacity="0.6" strokeDasharray="3 2" />
|
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":
|
case "error":
|
||||||
@ -318,8 +456,28 @@ function Hands({
|
|||||||
case "delighted":
|
case "delighted":
|
||||||
return (
|
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
|
||||||
<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" />
|
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:
|
default:
|
||||||
@ -330,16 +488,22 @@ function Hands({
|
|||||||
export function EvooCharacterSvg({
|
export function EvooCharacterSvg({
|
||||||
filterId,
|
filterId,
|
||||||
pose: rawPose,
|
pose: rawPose,
|
||||||
|
mouthPose: rawMouthPose,
|
||||||
pupilX,
|
pupilX,
|
||||||
pupilY,
|
pupilY,
|
||||||
isBlinking,
|
isBlinking,
|
||||||
|
isSmallBlinking = false,
|
||||||
isWinking,
|
isWinking,
|
||||||
isAsleep,
|
isAsleep,
|
||||||
hapticRipple = false,
|
hapticRipple = false,
|
||||||
|
reducedMotion = false,
|
||||||
}: EvooCharacterSvgProps) {
|
}: EvooCharacterSvgProps) {
|
||||||
const pose = normalizeEvooPose(isAsleep ? "asleep" : rawPose);
|
const pose = normalizeEvooPose(isAsleep ? "asleep" : rawPose);
|
||||||
|
const mouthNorm = normalizeEvooPose(isAsleep ? "asleep" : (rawMouthPose ?? rawPose));
|
||||||
const def = POSE_DEFINITIONS[pose];
|
const def = POSE_DEFINITIONS[pose];
|
||||||
|
const mouthDef = POSE_DEFINITIONS[mouthNorm];
|
||||||
const gaze = def.gaze ?? { x: 0, y: 0 };
|
const gaze = def.gaze ?? { x: 0, y: 0 };
|
||||||
|
const transition = `${EVOO_POSE_TRANSITION_S}s ${EVOO_CHARACTER_SPRING}`;
|
||||||
const finalPupilX = pupilX + gaze.x;
|
const finalPupilX = pupilX + gaze.x;
|
||||||
const finalPupilY = pupilY + gaze.y;
|
const finalPupilY = pupilY + gaze.y;
|
||||||
const eyeOpen = isAsleep ? 0.38 : (def.eyeOpen ?? 1);
|
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-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-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-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>
|
`}</style>
|
||||||
|
|
||||||
{hapticRipple ? (
|
{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}
|
) : null}
|
||||||
|
|
||||||
{def.aura === "success" ? (
|
{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}
|
) : null}
|
||||||
{def.aura === "speak" ? (
|
{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}
|
) : null}
|
||||||
|
|
||||||
<path
|
<path
|
||||||
d={mouthPath(def.mouth, isAsleep)}
|
d={mouthPath(mouthDef.mouth, isAsleep)}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#0F172A"
|
stroke="#0F172A"
|
||||||
strokeWidth="2.8"
|
strokeWidth="2.8"
|
||||||
strokeLinecap="round"
|
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 ? (
|
{def.browRaise ? (
|
||||||
@ -388,7 +579,13 @@ export function EvooCharacterSvg({
|
|||||||
<g
|
<g
|
||||||
className="evoo-eye-big"
|
className="evoo-eye-big"
|
||||||
data-evoo-brand={EVOO_OO_TRADEMARK}
|
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
|
<ellipse
|
||||||
cx="44"
|
cx="44"
|
||||||
@ -403,17 +600,53 @@ export function EvooCharacterSvg({
|
|||||||
filter: "drop-shadow(0 2px 6px rgba(0,0,0,0.22))",
|
filter: "drop-shadow(0 2px 6px rgba(0,0,0,0.22))",
|
||||||
transformOrigin: "44px 54px",
|
transformOrigin: "44px 54px",
|
||||||
transform: isBlinking ? "scaleY(0.08)" : `scaleY(${eyeOpen})`,
|
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) && (
|
{(!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="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="53" cy="49" r="4.6" fill="#FFFFFF" />
|
||||||
<circle cx="42" cy="63" r="2.2" fill="#FFFFFF" opacity="0.85" />
|
<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") && (
|
{(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>
|
</g>
|
||||||
)}
|
)}
|
||||||
@ -422,7 +655,13 @@ export function EvooCharacterSvg({
|
|||||||
<g
|
<g
|
||||||
className="evoo-eye-small"
|
className="evoo-eye-small"
|
||||||
data-evoo-brand={EVOO_OO_TRADEMARK}
|
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
|
<ellipse
|
||||||
cx="88"
|
cx="88"
|
||||||
@ -436,16 +675,54 @@ export function EvooCharacterSvg({
|
|||||||
style={{
|
style={{
|
||||||
filter: "drop-shadow(0 2px 5px rgba(0,0,0,0.18))",
|
filter: "drop-shadow(0 2px 5px rgba(0,0,0,0.18))",
|
||||||
transformOrigin: "88px 60px",
|
transformOrigin: "88px 60px",
|
||||||
transform: isBlinking || isWinking ? "scaleY(0.08)" : `scaleY(${eyeOpen})`,
|
transform: isSmallBlinking || isWinking ? "scaleY(0.08)" : `scaleY(${eyeOpen})`,
|
||||||
transition: "transform 0.18s cubic-bezier(0.16, 1, 0.3, 1)",
|
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 && (
|
{(!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 }}>
|
<g
|
||||||
<ellipse cx="90" cy="62" rx={EVOO_PUPIL_SMALL_R} ry={EVOO_PUPIL_SMALL_R} fill="#0A0F1D" />
|
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="93.2" cy="57" r="2.8" fill="#FFFFFF" />
|
||||||
<circle cx="86.8" cy="65.5" r="1.3" fill="#FFFFFF" opacity="0.85" />
|
<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>
|
||||||
)}
|
)}
|
||||||
</g>
|
</g>
|
||||||
@ -454,7 +731,14 @@ export function EvooCharacterSvg({
|
|||||||
<Accessory kind={def.accessory} filterId={filterId} />
|
<Accessory kind={def.accessory} filterId={filterId} />
|
||||||
) : null}
|
) : 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} />
|
<Hands preset={def.handPreset} filterId={filterId} />
|
||||||
</g>
|
</g>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -10,6 +10,13 @@ export {
|
|||||||
EVOO_PUPIL_SMALL_R,
|
EVOO_PUPIL_SMALL_R,
|
||||||
} from "./brand-lock.js";
|
} from "./brand-lock.js";
|
||||||
export { POSE_DEFINITIONS } from "./poses.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 {
|
export {
|
||||||
mapBotStatusToPose,
|
mapBotStatusToPose,
|
||||||
mapCollaborationToPose,
|
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",
|
mouth: "tiny-o",
|
||||||
gaze: { x: 0, y: -0.4 },
|
gaze: { x: 0, y: -0.4 },
|
||||||
accessory: "headset",
|
accessory: "headset",
|
||||||
handPreset: "listening",
|
handPreset: "rest",
|
||||||
bodyAnimation: "evoo-breathe-awake",
|
bodyAnimation: "evoo-breathe-awake",
|
||||||
aura: "speak",
|
aura: "speak",
|
||||||
},
|
},
|
||||||
speaking: {
|
speaking: {
|
||||||
mouth: "speaking",
|
mouth: "speaking",
|
||||||
gaze: { x: 0.3, y: 0 },
|
gaze: { x: 0.3, y: 0 },
|
||||||
handPreset: "speaking",
|
handPreset: "rest",
|
||||||
bodyAnimation: "evoo-conductor-left",
|
bodyAnimation: "evoo-conductor-left",
|
||||||
aura: "speak",
|
aura: "speak",
|
||||||
},
|
},
|
||||||
@ -25,20 +25,20 @@ export const POSE_DEFINITIONS: Record<EvooPose, EvooPoseDefinition> = {
|
|||||||
mouth: "flat",
|
mouth: "flat",
|
||||||
gaze: { x: 1.2, y: -1.1 },
|
gaze: { x: 1.2, y: -1.1 },
|
||||||
accessory: "thought",
|
accessory: "thought",
|
||||||
handPreset: "thinking",
|
handPreset: "rest",
|
||||||
aura: "think",
|
aura: "think",
|
||||||
},
|
},
|
||||||
working: {
|
working: {
|
||||||
mouth: "flat",
|
mouth: "flat",
|
||||||
gaze: { x: 0.6, y: 0.8 },
|
gaze: { x: 0.6, y: 0.8 },
|
||||||
handPreset: "typing",
|
handPreset: "rest",
|
||||||
bodyAnimation: "evoo-quantum-flicker",
|
bodyAnimation: "evoo-quantum-flicker",
|
||||||
},
|
},
|
||||||
reading: {
|
reading: {
|
||||||
mouth: "tiny-o",
|
mouth: "tiny-o",
|
||||||
gaze: { x: 1.4, y: 1.2 },
|
gaze: { x: 1.4, y: 1.2 },
|
||||||
accessory: "clipboard",
|
accessory: "clipboard",
|
||||||
handPreset: "reading",
|
handPreset: "rest",
|
||||||
eyeOpen: 0.92,
|
eyeOpen: 0.92,
|
||||||
},
|
},
|
||||||
analyzing: {
|
analyzing: {
|
||||||
@ -46,14 +46,14 @@ export const POSE_DEFINITIONS: Record<EvooPose, EvooPoseDefinition> = {
|
|||||||
gaze: { x: 0.8, y: 0.4 },
|
gaze: { x: 0.8, y: 0.4 },
|
||||||
accessory: "magnifier",
|
accessory: "magnifier",
|
||||||
browRaise: "left",
|
browRaise: "left",
|
||||||
handPreset: "analyzing",
|
handPreset: "rest",
|
||||||
eyeOpen: 0.88,
|
eyeOpen: 0.88,
|
||||||
},
|
},
|
||||||
skeptical: {
|
skeptical: {
|
||||||
mouth: "flat",
|
mouth: "flat",
|
||||||
browRaise: "right",
|
browRaise: "right",
|
||||||
gaze: { x: -0.6, y: -0.2 },
|
gaze: { x: -0.6, y: -0.2 },
|
||||||
handPreset: "skeptical",
|
handPreset: "rest",
|
||||||
eyeOpen: 0.9,
|
eyeOpen: 0.9,
|
||||||
},
|
},
|
||||||
curious: {
|
curious: {
|
||||||
@ -74,13 +74,13 @@ export const POSE_DEFINITIONS: Record<EvooPose, EvooPoseDefinition> = {
|
|||||||
mouth: "concerned",
|
mouth: "concerned",
|
||||||
browRaise: "worried",
|
browRaise: "worried",
|
||||||
gaze: { x: 0, y: 0.6 },
|
gaze: { x: 0, y: 0.6 },
|
||||||
handPreset: "concerned",
|
handPreset: "rest",
|
||||||
eyeOpen: 0.94,
|
eyeOpen: 0.94,
|
||||||
},
|
},
|
||||||
waiting: {
|
waiting: {
|
||||||
mouth: "flat",
|
mouth: "tiny-o",
|
||||||
gaze: { x: -0.5, y: 0 },
|
gaze: { x: -0.5, y: 0 },
|
||||||
handPreset: "waiting",
|
handPreset: "rest",
|
||||||
eyeOpen: 0.72,
|
eyeOpen: 0.72,
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
@ -112,7 +112,7 @@ export const POSE_DEFINITIONS: Record<EvooPose, EvooPoseDefinition> = {
|
|||||||
collaborating: {
|
collaborating: {
|
||||||
mouth: "soft-smile",
|
mouth: "soft-smile",
|
||||||
accessory: "tool",
|
accessory: "tool",
|
||||||
handPreset: "collaborating",
|
handPreset: "rest",
|
||||||
gaze: { x: 0.9, y: -0.3 },
|
gaze: { x: 0.9, y: -0.3 },
|
||||||
},
|
},
|
||||||
onboarding: {
|
onboarding: {
|
||||||
@ -125,7 +125,7 @@ export const POSE_DEFINITIONS: Record<EvooPose, EvooPoseDefinition> = {
|
|||||||
typing: {
|
typing: {
|
||||||
mouth: "flat",
|
mouth: "flat",
|
||||||
gaze: { x: 0.6, y: 0.8 },
|
gaze: { x: 0.6, y: 0.8 },
|
||||||
handPreset: "typing",
|
handPreset: "rest",
|
||||||
bodyAnimation: "evoo-quantum-flicker",
|
bodyAnimation: "evoo-quantum-flicker",
|
||||||
},
|
},
|
||||||
peek: {
|
peek: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user