Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions studio/knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@ const config: KnipConfig = {
"src/features/**",
// Reached only through the UI kit until later PRs in the stacked series
// land their first app-level consumers; each line leaves with that PR.
"src/hooks/use-is-truncated.ts",
"src/hooks/use-mobile.ts",
"src/lib/typography.ts",
],
ignoreDependencies: [
// Tailwind v4 is imported via CSS (@import "tailwindcss"), not JS
Expand All @@ -31,17 +29,13 @@ const config: KnipConfig = {
// (these leave the list as their first reached consumers land later in
// the stacked series: lucide-react/cmdk/tooltip with the workspace shell,
// select/switch/tabs with the surface pages)
"lucide-react",
"cmdk",
"@radix-ui/react-alert-dialog",
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
"@radix-ui/react-label",
"@radix-ui/react-scroll-area",
"@radix-ui/react-select",
"@radix-ui/react-switch",
"@radix-ui/react-tabs",
"@radix-ui/react-tooltip",
"@radix-ui/react-avatar",
"@radix-ui/react-checkbox",
"@radix-ui/react-popover",
Expand Down
52 changes: 52 additions & 0 deletions studio/src/app/workspace/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { TopNav } from "@/components/shell/top-nav";
import { RuntimeStatusProvider } from "@/features/agent/runtime-status";
import { StorageHealthBanner } from "@/features/agent/storage-health-banner";
import { ShortcutsProvider } from "@/lib/shortcuts/use-shortcuts";

/**
* The workspace shell: a fixed dark-green radial gradient carrying the top
* navigation bar, with all five surfaces rendered inside one rounded card
* that follows the theme. The gradient itself is a fixed brand colour —
* identical in light and dark themes — so only the card interior themes.
*
* `RuntimeStatusProvider` stays outermost: its offline banner renders above
* the top nav at full width. Workspace sections manage their own scrolling
* and padding inside the card (`h-full overflow-y-auto …`).
*/
export default function WorkspaceLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<RuntimeStatusProvider>
<ShortcutsProvider>
{/* The design's green radial gradient; dark mode deepens each stop so
the shell recedes behind the dark card instead of outglowing it.
Top padding tracks the status-bar safe area: the installed iOS
PWA (black-translucent status bar + viewport-fit cover) draws
under the clock, so the nav must start below it while the
gradient still paints behind it. */}
<div className="flex h-dvh min-w-0 flex-col bg-[radial-gradient(120%_140%_at_20%_30%,#006652_0%,#03433e_50%,#06202a_100%)] pt-[env(safe-area-inset-top)] dark:bg-[radial-gradient(120%_140%_at_20%_30%,#023d31_0%,#022723_50%,#02141b_100%)]">
{/* Storage health rides the same full-width banner band as the
runtime status banners: visible from every surface, because a
degraded store shows up as chats missing from the sidebar. */}
<StorageHealthBanner />
<TopNav />
{/* relative makes the card the containing block for absolutely-
positioned descendants with no positioned ancestor of their own
— notably the hidden form-integration checkbox Radix renders
beside each Switch inside a <form>. Without it those boxes
resolve to the document and grow the page itself. */}
{/* On mobile the card goes full-bleed — no gradient margin, only
the top corners stay rounded where it meets the nav band — so
content spans the screen; the gradient survives only behind the
top nav and the tab bar. The inset card is a ≥500px treatment. */}
<main className="relative min-h-0 flex-1 overflow-hidden rounded-t-xl bg-background text-foreground min-[500px]:mx-3 min-[500px]:mb-3 min-[500px]:rounded-[20px]">
{children}
</main>
</div>
</ShortcutsProvider>
</RuntimeStatusProvider>
);
}
65 changes: 65 additions & 0 deletions studio/src/app/workspace/shortcuts/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Metadata } from "next";
import { keycaps, SHORTCUT_GROUPS, SHORTCUTS } from "@/lib/shortcuts/registry";
import { pageTitleClass } from "@/lib/typography";

export const metadata: Metadata = { title: "Keyboard shortcuts — Workspace" };

/** A single keycap. */
function Key({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex min-w-[1.75rem] items-center justify-center rounded-md border border-border bg-muted px-2 py-1 font-mono text-xs font-medium text-foreground shadow-sm">
{children}
</kbd>
);
}

/**
* Reference page listing every keyboard shortcut, rendered directly from the
* shortcut registry so it can't drift from the live bindings. Reached from the
* profile menu and by pressing `?`.
*/
export default function KeyboardShortcutsPage() {
return (
<div className="h-full overflow-y-auto px-3 pt-6 pb-8 min-[500px]:px-4">
<div className="max-w-3xl space-y-6">
<div className="space-y-1">
<h1 className={pageTitleClass("pb-0 text-3xl leading-tight")}>
Keyboard shortcuts
</h1>
<p className="text-sm text-muted-foreground">
Work faster with the keyboard. On Windows and Linux, use Ctrl
wherever ⌘ is shown.
</p>
</div>

<div className="grid gap-6 sm:grid-cols-2">
{SHORTCUT_GROUPS.map((group) => (
<section key={group} className="rounded-xl border bg-card p-5">
<h2 className="mb-3 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{group}
</h2>
<ul className="space-y-2.5">
{SHORTCUTS.filter((s) => s.group === group).map((s) => (
<li
key={s.id}
className="flex items-center justify-between gap-4"
>
<span className="text-sm text-foreground">
{s.description}
</span>
<span className="flex shrink-0 items-center gap-1">
{keycaps(s.combo).map((k, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: positional keycaps
<Key key={i}>{k}</Key>
))}
</span>
</li>
))}
</ul>
</section>
))}
</div>
</div>
</div>
);
}
76 changes: 76 additions & 0 deletions studio/src/components/app/nav-items.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* The Atrium workspace navigation configuration — the single canonical
* nav-items module. The top nav is generic over the `ShellNav` built here:
* a single row of workspace destinations — the in-app chat plus the state
* that outlives a turn (Skills, Memory, Scheduled). The wordmark and home
* both point at Chats.
*/

import { Clock, GraduationCap, MessageCircle, Settings } from "lucide-react";
import type { ComponentType } from "react";
import { ATRIUM_WORKSPACE_HOME } from "@/lib/feature-flags";

interface NavItem {
/** Stable key for React keys and test selectors. */
readonly key: string;
/** Nav label (pill text when active, tooltip when inactive). */
readonly label: string;
/** Absolute route this destination points at. */
readonly href: string;
/** Nav icon. */
readonly icon: ComponentType<{ className?: string }>;
}

/**
* The console's navigation configuration. One value drives the whole shell:
* the top-nav destinations and the wordmark's home link.
*/
export interface ShellNav {
/** The console's root route (used for the "home" active-state rule). */
readonly homeHref: string;
/** Where the wordmark links, if different from `homeHref` (e.g. Atrium
sends the logo to the workspace chats rather than the gateway home). */
readonly logoHref?: string;
/** `aria-label` for the `<nav>` landmark. */
readonly navLabel: string;
/** Ordered nav destinations. */
readonly items: readonly NavItem[];
}

/** Builds the Atrium workspace `ShellNav`. */
export function buildUserNav(): ShellNav {
return {
// The workspace root (redirects to chat). Kept distinct from the Chats
// item's href so Chats highlights on any /workspace/chat/* route rather
// than only the exact base path (the "console root" exact-match rule).
homeHref: "/workspace",
logoHref: ATRIUM_WORKSPACE_HOME,
navLabel: "Main navigation",
items: [
{
key: "chat",
label: "Chats",
href: "/workspace/chat",
icon: MessageCircle,
},
{
key: "agent-schedules",
label: "Scheduled",
href: "/workspace/schedules",
icon: Clock,
},
{
key: "agent-skills",
label: "Skills",
href: "/workspace/skills",
icon: GraduationCap,
},
{
key: "settings",
label: "Settings",
href: "/workspace/settings",
icon: Settings,
},
],
};
}
82 changes: 82 additions & 0 deletions studio/src/components/shell/atrium-search-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Brain, CalendarClock, MessageSquare, Sparkles } from "lucide-react";
import type {
AgentSession,
CronJob,
MemoryEntry,
} from "@/features/agent/types";
import type { HarnessSkillInfo } from "@/lib/harness/client";
import type { SearchEntry, SearchGroup } from "./search-types";

/**
* Global-search index for the workspace surfaces, built from live daemon
* data on every change (Studio is daemon-only — there is nothing to index at
* module load). Transcripts are not indexed, so chat entries carry no body:
* matches land on titles and metadata only.
*/
export function buildAtriumSearchEntries(input: {
sessions: readonly AgentSession[];
jobs: readonly CronJob[];
skills: readonly HarnessSkillInfo[];
memories: readonly MemoryEntry[];
}): SearchEntry[] {
const entries: SearchEntry[] = [];

for (const session of input.sessions) {
entries.push({
id: `chat-${session.id}`,
source: "atrium",
category: "chat",
title: session.title,
subtitle: session.workspace || session.model || "Chat",
href: `/workspace/chat/${encodeURIComponent(session.id)}`,
icon: MessageSquare,
keywords: [session.id],
});
}

for (const job of input.jobs) {
entries.push({
id: `schedule-${job.name}`,
source: "atrium",
category: "schedule",
title: job.name,
subtitle: job.schedule,
href: `/workspace/schedules/${encodeURIComponent(job.name)}`,
icon: CalendarClock,
keywords: [job.instruction],
});
}

for (const skill of input.skills) {
entries.push({
id: `skill-${skill.name}`,
source: "atrium",
category: "skill",
title: skill.name,
subtitle: skill.description,
href: `/workspace/skills/${encodeURIComponent(skill.name)}`,
icon: Sparkles,
});
}

for (const entry of input.memories) {
entries.push({
id: `memory-${entry.id}`,
source: "atrium",
category: "memory",
title: entry.title,
subtitle: entry.content,
href: `/workspace/settings/memory/${encodeURIComponent(entry.id)}`,
icon: Brain,
});
}

return entries;
}

export const ATRIUM_SEARCH_GROUPS: readonly SearchGroup[] = [
{ category: "chat", heading: "Chats", source: "atrium" },
{ category: "memory", heading: "Memory", source: "atrium" },
{ category: "skill", heading: "Skills", source: "atrium" },
{ category: "schedule", heading: "Scheduled", source: "atrium" },
];
Loading
Loading