feat(web): replace Heute iframe with native daily brief (#72)
Replace Mission Control iframe on the Heute pillar with a native React brief that surfaces actionable runs, unread replies, and today’s threads. Mission Control remains available via a small footer link for power users. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
0474875142
commit
83302961ba
20
apps/web/e2e/heute-brief.spec.ts
Normal file
20
apps/web/e2e/heute-brief.spec.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
|
||||||
|
|
||||||
|
test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Heute" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/app$/);
|
||||||
|
|
||||||
|
const brief = page.getByTestId("heute-brief");
|
||||||
|
await expect(brief).toBeVisible();
|
||||||
|
await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0);
|
||||||
|
await expect(page.getByText("evOo Mission Control")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("heute-status")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("heute-mission-control-link")).toBeVisible();
|
||||||
|
|
||||||
|
await captureScreenshot(page, testInfo, "heute-native-brief");
|
||||||
|
});
|
||||||
@ -389,6 +389,16 @@
|
|||||||
"Not connected": "Nicht verbunden",
|
"Not connected": "Nicht verbunden",
|
||||||
"Not now": "Jetzt nicht",
|
"Not now": "Jetzt nicht",
|
||||||
"Now": "Jetzt",
|
"Now": "Jetzt",
|
||||||
|
"Next": "Weiter",
|
||||||
|
"All quiet": "Alles ruhig",
|
||||||
|
"# thing needs you": "1 Ding braucht dich",
|
||||||
|
"# things need you": "# Dinge brauchen dich",
|
||||||
|
"Quiet day so far": "Heute noch ruhig",
|
||||||
|
"Start a conversation with an Oo when you are ready.": "Starte eine Unterhaltung mit einem Oo, wenn du bereit bist.",
|
||||||
|
"Open a chat": "Chat öffnen",
|
||||||
|
"Create an Oo": "Oo anlegen",
|
||||||
|
"Unread reply": "Ungelesene Antwort",
|
||||||
|
"Mission Control": "Mission Control",
|
||||||
"Now using {0}.": "Jetzt wird {0} verwendet.",
|
"Now using {0}.": "Jetzt wird {0} verwendet.",
|
||||||
"OAuth authorization was cancelled.": "OAuth-Autorisierung wurde abgebrochen.",
|
"OAuth authorization was cancelled.": "OAuth-Autorisierung wurde abgebrochen.",
|
||||||
"OAuth connected": "OAuth verbunden",
|
"OAuth connected": "OAuth verbunden",
|
||||||
|
|||||||
128
apps/web/src/lib/heute-brief.test.ts
Normal file
128
apps/web/src/lib/heute-brief.test.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildHeuteBrief, isToday } from "./heute-brief";
|
||||||
|
|
||||||
|
const now = new Date("2026-09-22T12:00:00.000Z");
|
||||||
|
|
||||||
|
function bot(overrides: Partial<Bot> = {}): Bot {
|
||||||
|
return {
|
||||||
|
id: "bot-1",
|
||||||
|
spaceId: "space-1",
|
||||||
|
name: "Chief",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
instructions: "",
|
||||||
|
color: "#000",
|
||||||
|
notifyOnFinish: false,
|
||||||
|
pinned: false,
|
||||||
|
sectionId: null,
|
||||||
|
archivedAt: null,
|
||||||
|
unread: false,
|
||||||
|
parentBotId: null,
|
||||||
|
memoryScope: null,
|
||||||
|
threadId: "thread-1",
|
||||||
|
preview: "",
|
||||||
|
status: "idle",
|
||||||
|
computerMode: "team",
|
||||||
|
updatedAt: "2026-09-22T10:00:00.000Z",
|
||||||
|
createdAt: "2026-09-01T00:00:00.000Z",
|
||||||
|
voiceId: null,
|
||||||
|
autoSpeak: false,
|
||||||
|
modelProvider: null,
|
||||||
|
modelId: null,
|
||||||
|
thinkingLevel: null,
|
||||||
|
teamChatAmbientEnabled: false,
|
||||||
|
teamChatRules: "",
|
||||||
|
webhookConfigured: false,
|
||||||
|
spawnKey: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function group(overrides: Partial<Group> = {}): Group {
|
||||||
|
return {
|
||||||
|
id: "group-1",
|
||||||
|
spaceId: "space-1",
|
||||||
|
name: "Launch",
|
||||||
|
pinned: false,
|
||||||
|
sectionId: null,
|
||||||
|
archivedAt: null,
|
||||||
|
members: [],
|
||||||
|
threadId: "thread-g-1",
|
||||||
|
preview: "",
|
||||||
|
unread: false,
|
||||||
|
updatedAt: "2026-09-22T09:00:00.000Z",
|
||||||
|
createdAt: "2026-09-01T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(overrides: Partial<RunActivityRow> = {}): RunActivityRow {
|
||||||
|
return {
|
||||||
|
runId: "run-1",
|
||||||
|
botId: "bot-1",
|
||||||
|
botName: "Chief",
|
||||||
|
groupId: null,
|
||||||
|
groupName: null,
|
||||||
|
threadId: "thread-1",
|
||||||
|
status: "waiting_input",
|
||||||
|
trigger: "user",
|
||||||
|
notificationsEnabled: true,
|
||||||
|
promptSnippet: "Need approval",
|
||||||
|
updatedAt: "2026-09-22T11:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isToday", () => {
|
||||||
|
it("matches same calendar day", () => {
|
||||||
|
expect(isToday("2026-09-22T08:30:00.000Z", now)).toBe(true);
|
||||||
|
expect(isToday("2026-09-21T23:59:00.000Z", now)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildHeuteBrief", () => {
|
||||||
|
it("collects actionable runs, unread chats, and today follow-ups without duplicates", () => {
|
||||||
|
const brief = buildHeuteBrief({
|
||||||
|
bots: [
|
||||||
|
bot({ id: "bot-1", unread: true }),
|
||||||
|
bot({ id: "bot-2", unread: true, updatedAt: "2026-09-22T08:00:00.000Z", name: "Steve" }),
|
||||||
|
],
|
||||||
|
groups: [group({ updatedAt: "2026-09-22T09:30:00.000Z" })],
|
||||||
|
activeRuns: [run({ runId: "run-1", botId: "bot-1" })],
|
||||||
|
recentRuns: [
|
||||||
|
run({
|
||||||
|
runId: "run-2",
|
||||||
|
botId: "bot-3",
|
||||||
|
botName: "Ops",
|
||||||
|
status: "failed",
|
||||||
|
trigger: "routine",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(brief.actionCount).toBe(3);
|
||||||
|
expect(brief.jetzt.map((item) => item.kind)).toEqual(["run", "run", "unread"]);
|
||||||
|
expect(brief.weiter).toHaveLength(1);
|
||||||
|
expect(brief.weiter[0]?.chat.id).toBe("group-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps Weiter at five items", () => {
|
||||||
|
const bots = Array.from({ length: 7 }, (_, index) =>
|
||||||
|
bot({
|
||||||
|
id: `bot-${index}`,
|
||||||
|
name: `Bot ${index}`,
|
||||||
|
updatedAt: `2026-09-22T${String(index).padStart(2, "0")}:00:00.000Z`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const brief = buildHeuteBrief({
|
||||||
|
bots,
|
||||||
|
groups: [],
|
||||||
|
activeRuns: [],
|
||||||
|
recentRuns: [],
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
expect(brief.weiter).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
121
apps/web/src/lib/heute-brief.ts
Normal file
121
apps/web/src/lib/heute-brief.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||||
|
|
||||||
|
export type JetztRunItem = {
|
||||||
|
kind: "run";
|
||||||
|
run: RunActivityRow;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JetztUnreadItem = {
|
||||||
|
kind: "unread";
|
||||||
|
chatKind: "bot" | "group";
|
||||||
|
chat: Bot | Group;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JetztItem = JetztRunItem | JetztUnreadItem;
|
||||||
|
|
||||||
|
export type WeiterItem = {
|
||||||
|
chatKind: "bot" | "group";
|
||||||
|
chat: Bot | Group;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HeuteBriefData = {
|
||||||
|
jetzt: JetztItem[];
|
||||||
|
weiter: WeiterItem[];
|
||||||
|
actionCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isToday(iso: string, now = new Date()): boolean {
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return false;
|
||||||
|
return (
|
||||||
|
date.getDate() === now.getDate() &&
|
||||||
|
date.getMonth() === now.getMonth() &&
|
||||||
|
date.getFullYear() === now.getFullYear()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanBriefPreview(raw?: string | null): string {
|
||||||
|
if (!raw) return "";
|
||||||
|
let text = raw;
|
||||||
|
text = text.replace(/^#{1,6}\s+/gm, "");
|
||||||
|
text = text.replace(/\[(?:to|RECOVERY PROTOCOL|PO)[^\]]*\]/gi, "");
|
||||||
|
text = text.replace(/[*_`#]/g, "");
|
||||||
|
text = text.replace(/\\/g, "");
|
||||||
|
return text.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTargetKey(run: RunActivityRow): string {
|
||||||
|
return run.groupId ? `group:${run.groupId}` : `bot:${run.botId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatTargetKey(chatKind: "bot" | "group", chatId: string): string {
|
||||||
|
return chatKind === "group" ? `group:${chatId}` : `bot:${chatId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActionableRun(run: RunActivityRow): boolean {
|
||||||
|
return run.status === "waiting_input" || run.status === "waiting_takeover";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFailedRoutine(run: RunActivityRow): boolean {
|
||||||
|
return run.trigger === "routine" && run.status === "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHeuteBrief(params: {
|
||||||
|
bots: Bot[];
|
||||||
|
groups: Group[];
|
||||||
|
activeRuns: RunActivityRow[];
|
||||||
|
recentRuns: RunActivityRow[];
|
||||||
|
now?: Date;
|
||||||
|
}): HeuteBriefData {
|
||||||
|
const { bots, groups, activeRuns, recentRuns, now = new Date() } = params;
|
||||||
|
const jetzt: JetztItem[] = [];
|
||||||
|
const coveredTargets = new Set<string>();
|
||||||
|
|
||||||
|
const actionableRuns = [
|
||||||
|
...activeRuns.filter(isActionableRun),
|
||||||
|
...recentRuns.filter(isFailedRoutine),
|
||||||
|
];
|
||||||
|
const seenRunIds = new Set<string>();
|
||||||
|
for (const run of actionableRuns) {
|
||||||
|
if (seenRunIds.has(run.runId)) continue;
|
||||||
|
seenRunIds.add(run.runId);
|
||||||
|
jetzt.push({ kind: "run", run });
|
||||||
|
coveredTargets.add(runTargetKey(run));
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreadChats: JetztUnreadItem[] = [
|
||||||
|
...bots.filter((bot) => bot.unread).map((bot) => ({ kind: "unread" as const, chatKind: "bot" as const, chat: bot })),
|
||||||
|
...groups.filter((group) => group.unread).map((group) => ({
|
||||||
|
kind: "unread" as const,
|
||||||
|
chatKind: "group" as const,
|
||||||
|
chat: group,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
for (const item of unreadChats) {
|
||||||
|
const key = chatTargetKey(item.chatKind, item.chat.id);
|
||||||
|
if (coveredTargets.has(key)) continue;
|
||||||
|
jetzt.push(item);
|
||||||
|
coveredTargets.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weiterCandidates: WeiterItem[] = [
|
||||||
|
...bots
|
||||||
|
.filter((bot) => isToday(bot.updatedAt, now))
|
||||||
|
.map((bot) => ({ chatKind: "bot" as const, chat: bot })),
|
||||||
|
...groups
|
||||||
|
.filter((group) => isToday(group.updatedAt, now))
|
||||||
|
.map((group) => ({ chatKind: "group" as const, chat: group })),
|
||||||
|
];
|
||||||
|
weiterCandidates.sort(
|
||||||
|
(a, b) => new Date(b.chat.updatedAt).getTime() - new Date(a.chat.updatedAt).getTime(),
|
||||||
|
);
|
||||||
|
const weiter = weiterCandidates
|
||||||
|
.filter((item) => !coveredTargets.has(chatTargetKey(item.chatKind, item.chat.id)))
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
jetzt,
|
||||||
|
weiter,
|
||||||
|
actionCount: jetzt.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
372
apps/web/src/pages/HeuteBrief.tsx
Normal file
372
apps/web/src/pages/HeuteBrief.tsx
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
import { i18n } from "@lingui/core";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { Plural, Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
|
||||||
|
import { botColors } from "@rakazo/ui-tokens";
|
||||||
|
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
buildHeuteBrief,
|
||||||
|
cleanBriefPreview,
|
||||||
|
type JetztItem,
|
||||||
|
type WeiterItem,
|
||||||
|
} 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 { t } = useLingui();
|
||||||
|
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());
|
||||||
|
|
||||||
|
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} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
|
||||||
|
{loading && brief.jetzt.length === 0 && brief.weiter.length === 0 ? (
|
||||||
|
<p className="text-ui-body text-muted-foreground">
|
||||||
|
<Trans>Loading…</Trans>
|
||||||
|
</p>
|
||||||
|
) : brief.jetzt.length === 0 && brief.weiter.length === 0 ? (
|
||||||
|
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
|
||||||
|
{brief.jetzt.length > 0 ? (
|
||||||
|
<section data-testid="heute-jetzt">
|
||||||
|
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||||
|
<Trans>Now</Trans>
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{brief.jetzt.map((item) => (
|
||||||
|
<JetztRow
|
||||||
|
key={jetztRowKey(item)}
|
||||||
|
item={item}
|
||||||
|
botColorById={botColorById}
|
||||||
|
onOpenBot={onOpenBot}
|
||||||
|
onOpenGroup={onOpenGroup}
|
||||||
|
onOpenRun={onOpenRun}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{brief.weiter.length > 0 ? (
|
||||||
|
<section data-testid="heute-weiter">
|
||||||
|
<h2 className="rk-shell-section-label mb-2 px-0.5">
|
||||||
|
<Trans>Next</Trans>
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{brief.weiter.map((item) => (
|
||||||
|
<WeiterRow
|
||||||
|
key={`${item.chatKind}:${item.chat.id}`}
|
||||||
|
item={item}
|
||||||
|
onOpenBot={onOpenBot}
|
||||||
|
onOpenGroup={onOpenGroup}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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, loading }: { actionCount: number; loading: boolean }) {
|
||||||
|
if (loading && actionCount === 0) {
|
||||||
|
return <Trans>Loading…</Trans>;
|
||||||
|
}
|
||||||
|
if (actionCount === 0) {
|
||||||
|
return <Trans>All quiet</Trans>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Plural
|
||||||
|
value={actionCount}
|
||||||
|
one="# thing needs you"
|
||||||
|
other="# things need you"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 JetztRow({
|
||||||
|
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 || runStatusLabel(run.status);
|
||||||
|
const label = t`${title}, ${runStatusLabel(run.status)}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
data-testid={`heute-jetzt-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-semibold text-foreground">{title}</div>
|
||||||
|
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{subtitle}</div>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-ui-label font-medium text-warning">
|
||||||
|
{runStatusLabel(run.status)}
|
||||||
|
</span>
|
||||||
|
</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-jetzt-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>
|
||||||
|
<span className="inline-block size-2 shrink-0 rounded-full bg-primary" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WeiterRow({
|
||||||
|
item,
|
||||||
|
onOpenBot,
|
||||||
|
onOpenGroup,
|
||||||
|
}: {
|
||||||
|
item: WeiterItem;
|
||||||
|
onOpenBot: (botId: string, spaceId: string) => void;
|
||||||
|
onOpenGroup: (groupId: string, spaceId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const { chatKind, chat } = item;
|
||||||
|
const title = chat.name;
|
||||||
|
const preview =
|
||||||
|
cleanBriefPreview(chat.preview) ||
|
||||||
|
(chatKind === "group"
|
||||||
|
? (chat as Group).members.map((member) => member.name).join(", ")
|
||||||
|
: t`Ready`);
|
||||||
|
const label =
|
||||||
|
chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
data-testid={`heute-weiter-${chatKind}-${chat.id}`}
|
||||||
|
onClick={() =>
|
||||||
|
chatKind === "bot"
|
||||||
|
? onOpenBot(chat.id, chat.spaceId)
|
||||||
|
: onOpenGroup(chat.id, chat.spaceId)
|
||||||
|
}
|
||||||
|
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="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="truncate text-ui-body font-medium text-foreground">{title}</span>
|
||||||
|
<span className="shrink-0 text-ui-label text-muted-foreground">
|
||||||
|
{formatBriefTime(chat.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 truncate text-ui-body-sm text-muted-foreground">{preview}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jetztRowKey(item: JetztItem): string {
|
||||||
|
if (item.kind === "run") return `run:${item.run.runId}`;
|
||||||
|
return `unread:${item.chatKind}:${item.chat.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStatusLabel(status: RunActivityRow["status"]): string {
|
||||||
|
switch (status) {
|
||||||
|
case "waiting_input":
|
||||||
|
return t`Needs input`;
|
||||||
|
case "waiting_takeover":
|
||||||
|
return t`Needs takeover`;
|
||||||
|
case "failed":
|
||||||
|
return t`Failed`;
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeuteDate(date: Date): string {
|
||||||
|
const locale = i18n.locale || "de";
|
||||||
|
return date.toLocaleDateString(locale, {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
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" });
|
||||||
|
}
|
||||||
@ -91,7 +91,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
|
||||||
Gauge,
|
Gauge,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Lock,
|
Lock,
|
||||||
@ -108,7 +107,6 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Reply,
|
Reply,
|
||||||
RotateCw,
|
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Smile,
|
Smile,
|
||||||
@ -216,6 +214,7 @@ 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 { HostComputerPrompt } from "./HostComputerPrompt";
|
import { HostComputerPrompt } from "./HostComputerPrompt";
|
||||||
@ -357,7 +356,6 @@ export function ShellPage() {
|
|||||||
if (botId) return "team";
|
if (botId) return "team";
|
||||||
return "heute";
|
return "heute";
|
||||||
});
|
});
|
||||||
const [dashboardReloadKey, setDashboardReloadKey] = useState(0);
|
|
||||||
const [tradingMode, setTradingMode] = useState<"live" | "paper">(() => {
|
const [tradingMode, setTradingMode] = useState<"live" | "paper">(() => {
|
||||||
if (typeof window !== "undefined" && window.localStorage) {
|
if (typeof window !== "undefined" && window.localStorage) {
|
||||||
const saved = window.localStorage.getItem("evoo_trading_mode");
|
const saved = window.localStorage.getItem("evoo_trading_mode");
|
||||||
@ -425,38 +423,6 @@ export function ShellPage() {
|
|||||||
setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId));
|
setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId));
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
// Listen for navigation messages from embedded Mission Control iframe (Heute tab)
|
|
||||||
useEffect(() => {
|
|
||||||
const handleDashboardMessage = (event: MessageEvent) => {
|
|
||||||
if (!event.data || typeof event.data !== "object") return;
|
|
||||||
if (event.data.type === "open_team_tab") {
|
|
||||||
setActiveNavPillar("team");
|
|
||||||
const author = (event.data.botAuthor || event.data.botName || "").toLowerCase().trim();
|
|
||||||
if (author && botsRef.current.length > 0) {
|
|
||||||
const match = botsRef.current.find((b) => {
|
|
||||||
const bName = b.name.toLowerCase();
|
|
||||||
return (
|
|
||||||
bName.includes(author) ||
|
|
||||||
author.includes(bName) ||
|
|
||||||
(author.includes("steve") && bName.includes("steve")) ||
|
|
||||||
(author.includes("quant") && bName.includes("quant")) ||
|
|
||||||
(author.includes("architect") && bName.includes("architect")) ||
|
|
||||||
(author.includes("qa") && bName.includes("qa")) ||
|
|
||||||
(author.includes("devops") && bName.includes("devops")) ||
|
|
||||||
(author.includes("ux") && bName.includes("ux"))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (match) {
|
|
||||||
navigate(`/app/${match.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (event.data.type === "open_projekte_tab") {
|
|
||||||
setActiveNavPillar("projekte");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("message", handleDashboardMessage);
|
|
||||||
return () => window.removeEventListener("message", handleDashboardMessage);
|
|
||||||
}, [navigate]);
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
|
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
@ -3928,7 +3894,7 @@ export function ShellPage() {
|
|||||||
>
|
>
|
||||||
{activeNavPillar === "heute" ? (
|
{activeNavPillar === "heute" ? (
|
||||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||||
<div className="app-drag flex items-center justify-between border-b border-sidebar-border px-3 py-[17px] md:px-[22px] lg:px-8 2xl:px-12">
|
<div className="app-drag flex items-center border-b border-sidebar-border px-3 py-[17px] md:px-[22px] lg:px-8 2xl:px-12">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{botsSidebarCollapsed && desktopBridge() ? <WindowChrome /> : null}
|
{botsSidebarCollapsed && desktopBridge() ? <WindowChrome /> : null}
|
||||||
<button
|
<button
|
||||||
@ -3953,49 +3919,39 @@ export function ShellPage() {
|
|||||||
<PanelLeftClose strokeWidth={1.7} aria-hidden="true" className="size-icon-lg" />
|
<PanelLeftClose strokeWidth={1.7} aria-hidden="true" className="size-icon-lg" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="relative flex h-2.5 w-2.5">
|
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
|
||||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
|
|
||||||
</div>
|
|
||||||
<span className="text-ui-body font-semibold text-foreground tracking-tight">
|
|
||||||
evOo Mission Control
|
|
||||||
</span>
|
|
||||||
<span className="hidden sm:inline-flex text-ui-label font-mono px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 font-medium">
|
|
||||||
HEUTE · PORT 3088
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDashboardReloadKey((k) => k + 1)}
|
|
||||||
title={t`Reload`}
|
|
||||||
className="app-no-drag grid size-control-lg place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<RotateCw strokeWidth={1.8} className="size-icon-md" />
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href="http://127.0.0.1:3088"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
title={t`Open in new window`}
|
|
||||||
className="app-no-drag flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-ui-label font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<span>Vollbild</span>
|
|
||||||
<ExternalLink strokeWidth={1.8} className="size-icon-sm" />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex-1 w-full h-full bg-background overflow-hidden">
|
<HeuteBrief
|
||||||
<iframe
|
bots={bots}
|
||||||
key={dashboardReloadKey}
|
groups={groups}
|
||||||
src="http://127.0.0.1:3088/"
|
onOpenBot={(botId, spaceId) => {
|
||||||
className="w-full h-full border-0"
|
setActiveNavPillar("team");
|
||||||
title="evOo Mission Control"
|
openSpaceChat(spaceId, `/app/${botId}`);
|
||||||
/>
|
}}
|
||||||
</div>
|
onOpenGroup={(groupId, spaceId) => {
|
||||||
|
setActiveNavPillar("projekte");
|
||||||
|
openSpaceChat(spaceId, `/app/g/${groupId}`);
|
||||||
|
}}
|
||||||
|
onOpenRun={(run) => {
|
||||||
|
if (run.groupId) {
|
||||||
|
setActiveNavPillar("projekte");
|
||||||
|
navigate(`/app/g/${run.groupId}`);
|
||||||
|
} else {
|
||||||
|
setActiveNavPillar("team");
|
||||||
|
navigate(`/app/${run.botId}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onStartChat={() => {
|
||||||
|
const first = bots[0];
|
||||||
|
if (first) {
|
||||||
|
setActiveNavPillar("team");
|
||||||
|
openSpaceChat(first.spaceId, `/app/${first.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreateMenuOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user