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:
@@ -10,6 +10,8 @@
|
||||
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
||||
* A right-click on an installed row opens its Update / Set default /
|
||||
* Enable-Disable (plus Copy path) as a menu; the buttons stay.
|
||||
* A click on an installed row (outside its buttons) opens that tool's settings
|
||||
* tab when the hub gives it one (`settingsHref`); the name is the keyboard link.
|
||||
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
|
||||
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
|
||||
* working.
|
||||
@@ -19,12 +21,14 @@
|
||||
import { Switch } from "../Switch";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||
|
||||
@@ -115,9 +119,17 @@ function JobLog({ job }: { job: AiToolJob }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AiToolsSettings() {
|
||||
export function AiToolsSettings({
|
||||
settingsHref,
|
||||
}: {
|
||||
/** A tool's own settings page, when it has one. */
|
||||
settingsHref?: (id: string) => string | undefined;
|
||||
} = {}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const navigate = useNavigate();
|
||||
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
|
||||
// Installing, updating and switching tools changes the host: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [job, setJob] = useState<AiToolJob | null>(null);
|
||||
@@ -218,7 +230,7 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{updateAllTools.length > 0 && (
|
||||
{isAdmin && updateAllTools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm({ kind: "update-all" })}
|
||||
@@ -229,15 +241,17 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.updateAll", { count: updateAllTools.length })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void run("refresh", refresh)}
|
||||
disabled={busy !== null}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void run("refresh", refresh)}
|
||||
disabled={busy !== null}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
|
||||
@@ -248,12 +262,18 @@ export function AiToolsSettings() {
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
|
||||
{installed.map((tool) => {
|
||||
const isDefault = tool.id === defaultId;
|
||||
const href = settingsHref?.(tool.id);
|
||||
return (
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
onContextMenu={(e) => ctx.open(e, tool)}
|
||||
className="px-3 py-2.5"
|
||||
onClick={(e) => {
|
||||
// The row's own buttons, switch, links and log keep their click.
|
||||
if (href && !(e.target as Element).closest("button, a, input, pre"))
|
||||
navigate(href);
|
||||
}}
|
||||
className={`px-3 py-2.5 ${href ? "cursor-pointer hover:bg-surface-3" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
@@ -262,7 +282,17 @@ export function AiToolsSettings() {
|
||||
<AiToolIcon tool={tool} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
|
||||
<span className="truncate">{tool.label}</span>
|
||||
{href ? (
|
||||
<Link
|
||||
to={href}
|
||||
title={t("aiTools.openSettings", { tool: tool.label })}
|
||||
className="truncate hover:text-accent hover:underline"
|
||||
>
|
||||
{tool.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate">{tool.label}</span>
|
||||
)}
|
||||
<Homepage tool={tool} />
|
||||
{isDefault && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
@@ -279,31 +309,39 @@ export function AiToolsSettings() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Fixed slots, so the buttons line up from row to row. */}
|
||||
<div className="flex min-w-20 shrink-0 justify-end">
|
||||
{tool.update && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-28 shrink-0 justify-end">
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tool.enabled || busy !== null}
|
||||
onClick={() => setDefault(tool)}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ToolSwitch tool={tool} disabled={busy !== null} onToggle={() => toggle(tool)} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="flex min-w-20 shrink-0 justify-end">
|
||||
{tool.update && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-28 shrink-0 justify-end">
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tool.enabled || busy !== null}
|
||||
onClick={() => setDefault(tool)}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ToolSwitch
|
||||
tool={tool}
|
||||
disabled={busy !== null}
|
||||
onToggle={() => toggle(tool)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
@@ -312,7 +350,7 @@ export function AiToolsSettings() {
|
||||
</ul>
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-tool-menu">
|
||||
{ctx.menu.item.update && (
|
||||
{isAdmin && ctx.menu.item.update && (
|
||||
<MenuItem
|
||||
disabled={jobBusy}
|
||||
onSelect={pick((tool) => setConfirm({ kind: "update", tool }))}
|
||||
@@ -320,7 +358,7 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.update")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{ctx.menu.item.id !== defaultId && (
|
||||
{isAdmin && ctx.menu.item.id !== defaultId && (
|
||||
<MenuItem
|
||||
disabled={!ctx.menu.item.enabled || busy !== null}
|
||||
onSelect={pick(setDefault)}
|
||||
@@ -328,12 +366,14 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.setDefault")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
|
||||
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
|
||||
</MenuItem>
|
||||
{isAdmin && (
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
|
||||
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{ctx.menu.item.path && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{isAdmin && <MenuSeparator />}
|
||||
<MenuItem
|
||||
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
|
||||
>
|
||||
@@ -376,7 +416,7 @@ export function AiToolsSettings() {
|
||||
</span>
|
||||
</span>
|
||||
<Homepage tool={tool} />
|
||||
{tool.install && (
|
||||
{isAdmin && tool.install && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
|
||||
Reference in New Issue
Block a user