* 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>
411 lines
13 KiB
TypeScript
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",
|
|
});
|
|
}
|