Add workspaces, conversation sessions, app-managed computers and pictures

- Rail: Add icon with stacked form modals (bot, workspace, computer),
  coordinator, recent bots with "See all bots", Workspaces section, and
  a workspace mode listing only its crew
- Workspaces: page for context, bots (per-workspace instructions and
  memory), computers (default, link, unlink), models and delete;
  conversations scoped per bot per workspace
- Sessions: conversations split into searchable, reopenable sessions
  with carry-over summaries; sessions and new-session header icons
- Computers: created and provisioned over the docker socket, home dir
  kept at DATA_DIR/computers/<id> after delete; super admins only
- Models: workspace settings win over a bot's own, then the defaults
- Pictures for bots and workspaces; bots editable from the header
- Coordinator tools: update_workspace, set_bot_workspace_instructions

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 08:39:33 +01:00
co-authored by Claude Opus 5.5
parent 56daf0c462
commit 0ce61cdb49
223 changed files with 8166 additions and 585 deletions
+54
View File
@@ -0,0 +1,54 @@
import type { ComponentProps } from "react";
import Img from "@moduletrace/twui/layout/Img";
import { twMerge } from "tailwind-merge";
import profileImageUrls from "@/src/utils/profile-image-urls";
import BotStone from "./bot-stone";
/**
* Picture sizes, matching the stone sizes in use.
*/
const BotAvatarSizeClasses: Record<number, string> = {
18: "size-[18px] rounded-[4px]",
28: "size-7 rounded-default-sm",
32: "size-8 rounded-default-sm",
40: "size-10 rounded-default",
56: "size-14 rounded-default",
};
type Props = ComponentProps<typeof BotStone> & {
/**
* The bot's custom picture, shown instead of the stone.
*/
image_media_id?: number | string | null;
/**
* A picture to show instead, e.g. one just picked and not saved yet.
*/
src?: string;
};
/**
* A bot's avatar: its custom picture when it has one (a rounded square,
* never a circle, so it isn't mistaken for a person), else its stone. Its
* hue still marks it everywhere else, e.g. the unread dot.
*/
export default function BotAvatar({ image_media_id, src: picked, ...stone }: Props) {
const saved = profileImageUrls({ media_id: image_media_id });
const thumbnail_src = picked || saved.thumbnail_src;
const src = picked || saved.src;
const size = stone.size || 32;
if (!thumbnail_src) return <BotStone {...stone} />;
return (
<Img
src={thumbnail_src}
imgErrSrc={src}
alt={stone.label || ""}
className={twMerge(
"shrink-0 object-cover bg-sunken",
BotAvatarSizeClasses[size] || BotAvatarSizeClasses[32],
stone.className,
)}
/>
);
}
@@ -0,0 +1,94 @@
import { useRef, type ReactNode } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import imageInputToBase64 from "@moduletrace/twui/utils/form/imageInputToBase64";
type Props = {
/**
* The avatar as it looks now (or will, with the picked picture).
*/
preview: ReactNode;
has_image: boolean;
onPick: (params: { data_url: string }) => void;
onRemove: () => void;
loading?: boolean;
disabled?: boolean;
note?: string;
};
/**
* An avatar preview with Upload/Change and Remove. The picked picture comes
* back as a data URL; saving is up to the caller.
*/
export default function ImagePickField({
preview,
has_image,
onPick,
onRemove,
loading,
disabled,
note = "PNG, JPG or WebP. A square picture looks best.",
}: Props) {
const input_ref = useRef<HTMLInputElement>(null);
return (
<Row className="w-full gap-4! flex-nowrap items-center">
{preview}
<Stack className="gap-2! min-w-0">
<Row className="gap-2!">
<Button
title={has_image ? "Change picture" : "Upload picture"}
type="button"
variant="outlined"
color="gray"
size="small"
loading={loading}
disabled={disabled || loading}
onClick={() => input_ref.current?.click()}
>
{has_image ? "Change picture" : "Upload picture"}
</Button>
{has_image ? (
<Button
title="Remove the picture"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={disabled || loading}
onClick={() => {
onRemove();
if (input_ref.current) input_ref.current.value = "";
}}
>
Remove
</Button>
) : null}
</Row>
<Span className="text-[13px] leading-[18px] text-ink-secondary">{note}</Span>
</Stack>
{/*
* Raw on purpose: twui `Input` writes `value` on change, which
* browsers forbid on file inputs, and `ImageUpload` is a full
* card widget. This is only the hidden picker behind the button.
*/}
<input
ref={input_ref}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
tabIndex={-1}
aria-hidden
onChange={(e) => {
if (!e.target.files?.[0]) return;
imageInputToBase64({ imageInput: e.target }).then((res) => {
if (res?.imageBase64Full) onPick({ data_url: res.imageBase64Full });
});
}}
/>
</Row>
);
}
@@ -0,0 +1,54 @@
import Img from "@moduletrace/twui/layout/Img";
import Span from "@moduletrace/twui/layout/Span";
import { Layers } from "lucide-react";
import { twMerge } from "tailwind-merge";
import profileImageUrls from "@/src/utils/profile-image-urls";
const WorkspaceAvatarSizes = {
sm: { box: "size-8 rounded-default-sm", icon: 16 },
lg: { box: "size-14 rounded-default", icon: 24 },
} as const;
type Props = {
image_media_id?: number | string | null;
/**
* A picture to show instead, e.g. one just picked and not saved yet.
*/
src?: string;
size?: keyof typeof WorkspaceAvatarSizes;
className?: string;
};
/**
* A workspace's picture, or the workspace icon on a tile when it has none.
*/
export default function WorkspaceAvatar({ image_media_id, src: picked, size = "sm", className }: Props) {
const saved = profileImageUrls({ media_id: image_media_id });
const thumbnail_src = picked || saved.thumbnail_src;
const src = picked || saved.src;
const { box, icon } = WorkspaceAvatarSizes[size];
if (thumbnail_src) {
return (
<Img
src={thumbnail_src}
imgErrSrc={src}
alt=""
className={twMerge("shrink-0 object-cover bg-sunken", box, className)}
/>
);
}
return (
<Span
aria-hidden
className={twMerge(
"shrink-0 flex items-center justify-center border border-solid border-hairline bg-surface text-ink",
box,
className,
)}
>
<Layers size={icon} aria-hidden />
</Span>
);
}