evOo-local/apps/web/src/pages/HeuteBrief.tsx
cynarAI 5ae8f15931 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>
2026-09-22 14:39:40 +02:00

411 lines
13 KiB
TypeScript

import { i18n } from "@lingui/core";
import { Plural, Trans, useLingui } from "@lingui/react/macro";
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
import { botColors } from "@rakazo/ui-tokens";
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
import { ExternalLink } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { runActivityLabel } from "../lib/agent-activity";
import {
buildHeuteBrief,
cleanBriefPreview,
type DoingItem,
type DoneItem,
type JetztItem,
} from "../lib/heute-brief";
import { rpc } from "../lib/rpc";
const MISSION_CONTROL_URL = "http://127.0.0.1:3088/";
type HeuteBriefProps = {
bots: Bot[];
groups: Group[];
onOpenBot: (botId: string, spaceId: string) => void;
onOpenGroup: (groupId: string, spaceId: string) => void;
onOpenRun: (run: RunActivityRow) => void;
onStartChat: () => void;
};
export function HeuteBrief({
bots,
groups,
onOpenBot,
onOpenGroup,
onOpenRun,
onStartChat,
}: HeuteBriefProps) {
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
const [loading, setLoading] = useState(true);
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;
setActiveRuns(active.runs);
setRecentRuns(recent.runs);
} catch {
if (cancelled) return;
} finally {
if (!cancelled) {
setLoading(false);
timer = window.setTimeout(() => void tick(), 15_000);
}
}
};
void tick();
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, []);
const brief = useMemo(
() =>
buildHeuteBrief({
bots,
groups,
activeRuns,
recentRuns,
}),
[activeRuns, bots, groups, recentRuns],
);
const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]);
const dateLabel = formatHeuteDate(new Date());
const hasContent = brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief">
<div className="border-b border-border px-4 py-5 md:px-8 lg:px-10">
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">{dateLabel}</h1>
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
<StatusLine
actionCount={brief.actionCount}
loading={loading}
doingCount={brief.doing.length}
/>
</p>
</div>
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
{loading && !hasContent ? (
<p className="text-ui-body text-muted-foreground">
<Trans>Loading…</Trans>
</p>
) : !hasContent ? (
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
) : (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
{brief.needsYou.length > 0 ? (
<section data-testid="heute-needs-you">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Needs you</Trans>
</h2>
<div className="flex flex-col gap-1">
{brief.needsYou.map((item) => (
<NeedsYouRow
key={jetztRowKey(item)}
item={item}
botColorById={botColorById}
onOpenBot={onOpenBot}
onOpenGroup={onOpenGroup}
onOpenRun={onOpenRun}
/>
))}
</div>
{brief.actionCount > brief.needsYou.length ? (
<p className="mt-2 px-0.5 text-ui-label text-muted-foreground">
<Plural
value={brief.actionCount - brief.needsYou.length}
one="# more waiting"
other="# more waiting"
/>
</p>
) : null}
</section>
) : null}
{brief.doing.length > 0 ? (
<section data-testid="heute-doing">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Doing</Trans>
</h2>
<div className="flex flex-col gap-1">
{brief.doing.map((item) => (
<DoingRow
key={`doing:${item.run.runId}`}
item={item}
botColorById={botColorById}
onOpenRun={onOpenRun}
/>
))}
</div>
</section>
) : null}
{brief.done.length > 0 ? (
<section data-testid="heute-done">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Done</Trans>
</h2>
<div className="flex flex-col gap-1">
{brief.done.map((item) => (
<DoneRow
key={`done:${item.run.runId}`}
item={item}
botColorById={botColorById}
onOpenRun={onOpenRun}
/>
))}
</div>
</section>
) : null}
</div>
)}
<footer className="mx-auto mt-10 flex w-full max-w-2xl justify-center pb-4">
<a
href={MISSION_CONTROL_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground/70 transition-colors hover:text-muted-foreground"
data-testid="heute-mission-control-link"
>
<Trans>Mission Control</Trans>
<ExternalLink strokeWidth={1.8} className="size-icon-sm" aria-hidden="true" />
</a>
</footer>
</div>
</div>
);
}
function StatusLine({
actionCount,
doingCount,
loading,
}: {
actionCount: number;
doingCount: number;
loading: boolean;
}) {
if (loading && actionCount === 0 && doingCount === 0) {
return <Trans>Loading…</Trans>;
}
if (actionCount > 0) {
return <Plural value={actionCount} one="# thing needs you" other="# things need you" />;
}
if (doingCount > 0) {
return <Plural value={doingCount} one="# Oo is working" other="# Oos are working" />;
}
return <Trans>All quiet</Trans>;
}
function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots: boolean }) {
return (
<div
className="mx-auto flex w-full max-w-md flex-col items-center gap-5 py-16 text-center"
data-testid="heute-empty"
>
<EvooMascot size={72} pose="greeting" />
<div className="space-y-2">
<p className="text-ui-body-lg font-medium text-foreground">
<Trans>Quiet day so far</Trans>
</p>
<p className="text-ui-body text-muted-foreground">
<Trans>Start a conversation with an Oo when you are ready.</Trans>
</p>
</div>
<Button type="button" onClick={onStartChat} data-testid="heute-start-chat">
{hasBots ? <Trans>Open a chat</Trans> : <Trans>Create an Oo</Trans>}
</Button>
</div>
);
}
function ActionableChip({ label }: { label: string }) {
return (
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-warning">
{label}
</span>
);
}
function NeedsYouRow({
item,
botColorById,
onOpenBot,
onOpenGroup,
onOpenRun,
}: {
item: JetztItem;
botColorById: Map<string, string>;
onOpenBot: (botId: string, spaceId: string) => void;
onOpenGroup: (groupId: string, spaceId: string) => void;
onOpenRun: (run: RunActivityRow) => void;
}) {
const { t } = useLingui();
if (item.kind === "run") {
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-needs-you-run-${run.runId}`}
onClick={() => onOpenRun(run)}
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-3 py-3 text-start transition-colors hover:border-border hover:bg-accent"
>
<BotAvatar
color={botColorById.get(run.botId) ?? botColors[3]}
identity={run.botId}
size={40}
status={run.status}
/>
<div className="min-w-0 flex-1">
<div className="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>
<ActionableChip label={t`Needs you`} />
</button>
);
}
const { chatKind, chat } = item;
const title = chat.name;
const preview = cleanBriefPreview(chat.preview) || t`Unread reply`;
const label =
chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`;
return (
<button
type="button"
aria-label={label}
data-testid={`heute-needs-you-unread-${chatKind}-${chat.id}`}
onClick={() =>
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"
>
{chatKind === "bot" ? (
<BotAvatar
color={(chat as Bot).color}
identity={chat.id}
size={40}
status={(chat as Bot).status}
/>
) : (
<GroupAvatar members={(chat as Group).members} size={40} />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-ui-body font-semibold text-foreground">{title}</div>
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{preview}</div>
</div>
<ActionableChip label={t`Unread`} />
</button>
);
}
function DoingRow({
item,
botColorById,
onOpenRun,
}: {
item: DoingItem;
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-doing-run-${run.runId}`}
onClick={() => onOpenRun(run)}
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-3 py-3 text-start transition-colors hover:border-border hover:bg-accent"
>
<BotAvatar
color={botColorById.get(run.botId) ?? botColors[3]}
identity={run.botId}
size={40}
status={run.status}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-ui-body font-medium text-foreground">{title}</div>
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{subtitle}</div>
</div>
<span className="shrink-0 text-ui-label text-muted-foreground">
{runActivityLabel(run.status)}
</span>
</button>
);
}
function DoneRow({
item,
botColorById,
onOpenRun,
}: {
item: DoneItem;
botColorById: Map<string, string>;
onOpenRun: (run: RunActivityRow) => void;
}) {
const { t } = useLingui();
const run = item.run;
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
const subtitle = run.promptSnippet || runActivityLabel(run.status);
const label = t`${title}, ${runActivityLabel(run.status)}`;
return (
<button
type="button"
aria-label={label}
data-testid={`heute-done-run-${run.runId}`}
onClick={() => onOpenRun(run)}
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-3 py-3 text-start transition-colors hover:border-border hover:bg-accent"
>
<BotAvatar
color={botColorById.get(run.botId) ?? botColors[3]}
identity={run.botId}
size={40}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-ui-body font-medium text-foreground">{title}</div>
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{subtitle}</div>
</div>
<span className="shrink-0 text-ui-label text-success">{runActivityLabel(run.status)}</span>
</button>
);
}
function jetztRowKey(item: JetztItem): string {
if (item.kind === "run") return `run:${item.run.runId}`;
return `unread:${item.chatKind}:${item.chat.id}`;
}
function formatHeuteDate(date: Date): string {
const locale = i18n.locale || "de";
return date.toLocaleDateString(locale, {
weekday: "long",
day: "numeric",
month: "long",
});
}