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:
cynarAI 2026-09-22 12:21:05 +02:00 committed by GitHub
parent 0474875142
commit 83302961ba
6 changed files with 683 additions and 76 deletions

View 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");
});

View File

@ -389,6 +389,16 @@
"Not connected": "Nicht verbunden",
"Not now": "Jetzt nicht",
"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.",
"OAuth authorization was cancelled.": "OAuth-Autorisierung wurde abgebrochen.",
"OAuth connected": "OAuth verbunden",

View 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);
});
});

View 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,
};
}

View 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" });
}

View File

@ -91,7 +91,6 @@ import {
ChevronDown,
Clock,
Copy,
ExternalLink,
Gauge,
LayoutGrid,
Lock,
@ -108,7 +107,6 @@ import {
Plus,
Puzzle,
Reply,
RotateCw,
Search,
Settings,
Smile,
@ -216,6 +214,7 @@ import {
} from "../lib/transcript-scroll";
import { speaker } from "../lib/tts";
import { ActivityList } from "./ActivityList";
import { HeuteBrief } from "./HeuteBrief";
import type { ContextMenuPosition } from "./BotContextMenu";
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
import { HostComputerPrompt } from "./HostComputerPrompt";
@ -357,7 +356,6 @@ export function ShellPage() {
if (botId) return "team";
return "heute";
});
const [dashboardReloadKey, setDashboardReloadKey] = useState(0);
const [tradingMode, setTradingMode] = useState<"live" | "paper">(() => {
if (typeof window !== "undefined" && window.localStorage) {
const saved = window.localStorage.getItem("evoo_trading_mode");
@ -425,38 +423,6 @@ export function ShellPage() {
setBotsSidebarCollapsed(readBotsSidebarCollapsed(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 [searchHits, setSearchHits] = useState<SearchHit[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
@ -3928,7 +3894,7 @@ export function ShellPage() {
>
{activeNavPillar === "heute" ? (
<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">
{botsSidebarCollapsed && desktopBridge() ? <WindowChrome /> : null}
<button
@ -3953,49 +3919,39 @@ export function ShellPage() {
<PanelLeftClose strokeWidth={1.7} aria-hidden="true" className="size-icon-lg" />
)}
</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 className="relative flex-1 w-full h-full bg-background overflow-hidden">
<iframe
key={dashboardReloadKey}
src="http://127.0.0.1:3088/"
className="w-full h-full border-0"
title="evOo Mission Control"
/>
</div>
<HeuteBrief
bots={bots}
groups={groups}
onOpenBot={(botId, spaceId) => {
setActiveNavPillar("team");
openSpaceChat(spaceId, `/app/${botId}`);
}}
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>
) : (
<>