feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall

- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
2026-10-08 23:04:57 +07:00
parent a9c71cd905
commit b7f3735b29
147 changed files with 5644 additions and 2561 deletions
+49 -39
View File
@@ -28,6 +28,7 @@ import {
} from "lucide-react";
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
import { usePets } from "./usePets";
import { usePermissions } from "../AuthGate";
import { toast } from "../../lib/toast";
const ITEM =
@@ -50,6 +51,8 @@ export function PetMenu({
const bundleInput = useRef<HTMLInputElement>(null);
const [choosing, setChoosing] = useState(false);
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
// Adding or deleting a pet changes the whole install: an admin's.
const { isAdmin } = usePermissions();
// Closes on a press outside the menu, or Escape.
useEffect(() => {
@@ -133,6 +136,7 @@ export function PetMenu({
</button>
{list?.featured.map((f) => {
const have = installed.get(f.id);
if (!have && !isAdmin) return null;
return (
<button
key={f.id}
@@ -173,48 +177,54 @@ export function PetMenu({
<span className="min-w-0 truncate">{p.displayName}</span>
{mark(p.id)}
</button>
<button
type="button"
data-testid={`pet-delete-${p.id}`}
aria-label={t("sagi.pet.delete", { name: p.displayName })}
title={t("sagi.pet.delete", { name: p.displayName })}
disabled={busy !== null}
onClick={() => void remove(p)}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
{isAdmin && (
<button
type="button"
data-testid={`pet-delete-${p.id}`}
aria-label={t("sagi.pet.delete", { name: p.displayName })}
title={t("sagi.pet.delete", { name: p.displayName })}
disabled={busy !== null}
onClick={() => void remove(p)}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
<div className="my-1.5 border-t border-border" />
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PackageOpen className="h-4 w-4" />
)}
{t("sagi.pet.import")}
</button>
{isAdmin && (
<>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PackageOpen className="h-4 w-4" />
)}
{t("sagi.pet.import")}
</button>
</>
)}
<a
href="https://codex-pets.net/"
target="_blank"