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
2 changes: 1 addition & 1 deletion docs/design/PRODUCTION-READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ record; current behaviour is in the linked [architecture](../architecture.md) do
| mecak8s (storage-free k8s-native agent) | ✅ shipped (MVP) · ✅ OPT-IN `/metrics` loopback scrape + OTLP push (ADR 0098) · ✅ verified external Redis TLS/ACL with transactional projected-file reload + last-valid generations (ADR 0240) · ✅ Helm 0.3.0 secure real-provider in-pod TLS+OIDC or edge-terminated TLS+OIDC (ClusterIP h2c), nullable spend ceilings, and pod scheduling controls · ⛔ CRD/Operator · ⛔ HPA (custom-metrics on active-runs) · ⛔ managed Redis provisioning (ElastiCache/MemoryStore — endpoint only) · ⛔ fix `mecated`'s unbounded `GracefulStop` (pre-existing, follow-up) | [mecak8s.md](../adr/0048-mecak8s.md) · [0098](../adr/0098-headless-telemetry.md) · [0240](../adr/0240-mecak8s-credential-reload-and-chart-security.md) · [0278](../adr/0278-mecak8s-edge-terminated-tls.md) · [MECAK8S-PLAN.md](./MECAK8S-PLAN.md) | [overview](../architecture.md) |
| ACP adapter (editor stdio surface) | ✅ Phase 1+2 + bounded Phase 3 + multimodal shipped · ⛔ Phase 3 long-tail (rule persistence, grep-over-buffers, fs/* on resume) | [0001-acp-adapter.md](../adr/0001-acp-adapter.md) | [api surface](../architecture/api-surface.md) |
| Conversation fork (peer session from a history snapshot) | ✅ shipped · ✅ effort override (mid-conversation effort switch, keeps the transcript — [0068](../adr/0068-effort-change-via-fork.md)) · ⛔ cross-provider/model fork (v2: replay-blob stripping) · ⛔ workspace-branching fork · ⛔ fork-from-event-log-at-arbitrary-point · ⛔ fork lineage (`forked_from` label) | [0065-conversation-fork.md](../adr/0065-conversation-fork.md) | [overview](../architecture.md) |
| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · Memory · Settings surfaces · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) |
| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · Memory (read-only table + detail + consolidate, honest disabled/empty states) · ✅ Settings core (Personalize, agent identity, learning review) · ⛔ provider/model-router/gateway settings · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) |
| _Historical / retired_ | — | [ARCHITECTURE.md](../adr/0004-v1-architecture.md) · [STEP-CHAIN.md](../adr/0006-v1-step-chain.md) · [TWELVE-PATTERNS-AUDIT.md](../adr/0007-twelve-patterns-audit.md) · [REPOMAP-TREE-SITTER.md](../adr/0029-repomap-tree-sitter.md) | — |

## Security
Expand Down
1 change: 0 additions & 1 deletion studio/knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ 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-mobile.ts",
],
ignoreDependencies: [
// Tailwind v4 is imported via CSS (@import "tailwindcss"), not JS
Expand Down
97 changes: 97 additions & 0 deletions studio/src/app/workspace/memory/[memoryId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"use client";

import { notFound, useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { useAgentMemory } from "@/features/agent";
import { pageTitleClass } from "@/lib/typography";

/**
* A remembered fact as its own full page — deliberately OUTSIDE the settings
* layout (no settings nav), the same dedicated-detail treatment skills and
* schedules get. The list it backs out to stays under Settings → Memory.
*/
export default function MemoryDetailPage() {
const router = useRouter();
const params = useParams<{ memoryId: string }>();
const memory = useAgentMemory();
// The route segment arrives URL-encoded; entry ids are the raw store keys.
const key = decodeURIComponent(params.memoryId);
const entry = memory.entries.find((e) => e.id === key);

if (!entry) {
if (memory.isLoading) {
return (
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
Loading…
</div>
);
}
if (!memory.isSupported) {
return (
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-2 px-6 text-center">
<p className="text-sm font-medium">
Memory is disabled on this daemon
</p>
{memory.disabledReason && (
<p className="text-sm text-muted-foreground">
{memory.disabledReason}
</p>
)}
</div>
);
}
return notFound();
}

return (
<div className="h-full overflow-y-auto px-3 pt-6 pb-8 min-[500px]:px-4">
<div className="space-y-5">
<Button
variant="outline"
size="sm"
className="h-9 w-fit gap-1 self-start rounded-full px-4"
onClick={() => router.push("/workspace/settings/memory")}
>
<span aria-hidden="true">‹</span>
Back
</Button>

{/* The schedules/skills detail grammar: serif title, pill row, the
content leading unlabelled as its own card, then grouped facts. */}
<h1
className={pageTitleClass(
"break-all text-[44px] leading-[1.05] max-[499px]:text-3xl",
)}
>
{entry.title}
</h1>

<div className="max-w-4xl space-y-8">
<p className="rounded-xl border bg-card p-5 text-sm leading-relaxed whitespace-pre-wrap">
{entry.content || "No description recorded."}
</p>

<div className="space-y-2">
<h2 className="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
Details
</h2>
<div className="divide-y rounded-lg border bg-background">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<span className="text-sm">Key</span>
<span className="break-all text-right font-mono text-sm text-muted-foreground">
{entry.id}
</span>
</div>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<span className="text-sm">Source</span>
<span className="text-right text-sm text-muted-foreground">
Learned in conversation
</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
6 changes: 6 additions & 0 deletions studio/src/app/workspace/memory/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";

/** Memory moved under Settings; keep old bookmarks working. */
export default function LegacyMemoryPage() {
redirect("/workspace/settings/memory");
}
120 changes: 120 additions & 0 deletions studio/src/app/workspace/settings/_components/avatar-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"use client";

import { X } from "lucide-react";
import { useRef } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";

/** Longest edge of a stored avatar; they render at 56px, so this is ample. */
const AVATAR_MAX_DIM = 512;

/**
* Downscale a picked image in the browser so any size of upload fits
* comfortably in local storage: longest edge capped at AVATAR_MAX_DIM,
* re-encoded as JPEG (composited over white — JPEG has no alpha).
*/
async function downscaleAvatar(file: File): Promise<string> {
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const el = new Image();
el.onload = () => resolve(el);
el.onerror = () => reject(new Error("undecodable image"));
el.src = url;
});
const scale = Math.min(
1,
AVATAR_MAX_DIM / Math.max(img.naturalWidth, img.naturalHeight),
);
const width = Math.max(1, Math.round(img.naturalWidth * scale));
const height = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas unavailable");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
return canvas.toDataURL("image/jpeg", 0.85);
} finally {
URL.revokeObjectURL(url);
}
}

/**
* A stored-picture control: circular preview (or the given fallback),
* upload/change via the file picker with in-browser downscaling, and remove.
* Backed by any of the browser-local avatar preferences.
*/
export function AvatarPicker({
avatarUrl,
onChange,
alt,
fallback,
}: {
avatarUrl: string | null;
onChange: (next: string | null) => void;
alt: string;
/** Rendered inside the circle when no picture is stored. */
fallback: React.ReactNode;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);

function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Choose an image file.");
return;
}
downscaleAvatar(file)
.then(onChange)
.catch(() => toast.error("Could not read that image."));
}

return (
<div className="flex items-center gap-4">
<div className="group relative size-14 shrink-0">
<div className="flex size-full items-center justify-center overflow-hidden rounded-full bg-muted text-muted-foreground">
{avatarUrl ? (
// biome-ignore lint/performance/noImgElement: a locally stored data URL, not a remote image
<img src={avatarUrl} alt={alt} className="size-full object-cover" />
) : (
fallback
)}
</div>
{/* Remove lives on the picture itself: hover (or keyboard focus)
reveals a small ×; without a picture there is nothing to remove. */}
{avatarUrl && (
<button
type="button"
aria-label="Remove picture"
onClick={() => onChange(null)}
className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 hover:text-foreground focus-visible:opacity-100"
>
<X className="size-3" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<Button
variant="outline"
size="sm"
className="rounded-full"
onClick={() => fileInputRef.current?.click()}
>
{avatarUrl ? "Change picture" : "Upload picture"}
</Button>
</div>
</div>
);
}
131 changes: 131 additions & 0 deletions studio/src/app/workspace/settings/_components/option-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"use client";

import { Check, ChevronDown } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";

export interface OptionItem {
value: string;
label: string;
icon?: React.ComponentType<{ className?: string }>;
}

/**
* A select-style settings control: a trigger button showing the current
* choice that opens a dropdown on desktop and a bottom sheet on mobile
* (the app's picker convention — pill groups don't scale on small screens).
*/
export function OptionField({
label,
value,
options,
onChange,
}: {
/** Accessible name for the control (the visible label lives beside it). */
label: string;
value: string;
options: readonly OptionItem[];
onChange: (value: string) => void;
}) {
const isMobile = useIsMobile();
const [sheetOpen, setSheetOpen] = useState(false);
const current = options.find((option) => option.value === value);
const CurrentIcon = current?.icon;

const trigger = (
<Button
variant="outline"
className="h-9 w-44 justify-between gap-2 rounded-lg px-3 font-normal"
aria-label={label}
onClick={isMobile ? () => setSheetOpen(true) : undefined}
>
<span className="flex min-w-0 items-center gap-2">
{CurrentIcon && (
<CurrentIcon className="size-4 text-muted-foreground" />
)}
<span className="truncate">{current?.label ?? value}</span>
</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
</Button>
);

if (isMobile) {
return (
<>
{trigger}
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent side="bottom" className="p-0">
<SheetTitle className="sr-only">{label}</SheetTitle>
<div className="py-2">
{options.map((option) => {
const Icon = option.icon;
return (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value);
setSheetOpen(false);
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-muted/50"
>
{Icon && <Icon className="size-4 text-muted-foreground" />}
<span className="min-w-0 flex-1 truncate text-left font-medium">
{option.label}
</span>
<Check
className={cn(
"size-4 shrink-0",
option.value === value
? "text-foreground"
: "text-transparent",
)}
/>
</button>
);
})}
</div>
</SheetContent>
</Sheet>
</>
);
}

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-36">
{options.map((option) => {
const Icon = option.icon;
return (
<DropdownMenuItem
key={option.value}
className="gap-2"
onClick={() => onChange(option.value)}
>
<Check
className={cn(
"size-4",
option.value === value
? "text-foreground"
: "text-transparent",
)}
/>
{Icon && <Icon className="size-4 text-muted-foreground" />}
{option.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
Loading
Loading