feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
This commit is contained in:
@@ -5,84 +5,319 @@
|
||||
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
|
||||
* attached underneath — a message is sent through that terminal
|
||||
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
|
||||
* nothing new reaches the server. A permission prompt or a menu still needs the
|
||||
* terminal; the hint under the box says so.
|
||||
* nothing new reaches the server. The box carries Claude Desktop's controls:
|
||||
* the `+` menu (`ChatAddMenu`: files — new, the project's, the workspace's
|
||||
* context files, recent uploads —, skills, plugins' skills), and for a
|
||||
* Claude pane the Model/Effort chip — a change is typed into the pane as
|
||||
* `/model <id>` / `/effort <level>` — and the permission mode, which only
|
||||
* Shift+Tab changes in a running session, so the server steps it and reads
|
||||
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
|
||||
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
|
||||
* Shown alone (`outline`), it also has Claude Desktop's activity panel
|
||||
* (`ChatOutline`: progress, outputs, what the session used): collapsed into a
|
||||
* file button with the output count until clicked (remembered per browser);
|
||||
* a file clicked there opens its preview in the panel's place
|
||||
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
|
||||
* the workspace's folder —, a group
|
||||
* of files a list with a preview (`FileListModal`). A file in a turn's
|
||||
* "Edited N files" card opens in the Workspace's Changes panel
|
||||
* (`openInChanges`) when it lies in the pane's folder. A send scrolls the
|
||||
* conversation to its end and keeps it there (`follow`), so the message and
|
||||
* the step running after it come into sight on their own. While the session's
|
||||
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
||||
* Stop in Send's place: Esc pressed in the pane (`interruptRun`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { ArrowUp, FileText, Square } from "lucide-react";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import { PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
import { submitToRun } from "../../lib/panePaste";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||
import { ChatAddMenu } from "./ChatAddMenu";
|
||||
import { addAttachments } from "../../lib/promptAttach";
|
||||
import { ChatOutline, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||
import { FileListModal, FilePreview, type PreviewScope } from "./ChatFilePreview";
|
||||
import { Select } from "../Select";
|
||||
import { interruptRun, submitToRun } from "../../lib/panePaste";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import { openInChanges } from "../../lib/openInChanges";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
||||
|
||||
const MODES: [PermissionMode, string][] = [
|
||||
["default", "Default"],
|
||||
["acceptEdits", "AcceptEdits"],
|
||||
["plan", "Plan"],
|
||||
["auto", "Auto"],
|
||||
["bypassPermissions", "Bypass"],
|
||||
];
|
||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
|
||||
|
||||
/** The pane's permission mode as it shows it; a pick steps it there. */
|
||||
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
|
||||
const { t } = useTranslation(["run", "lanes"]);
|
||||
const [mode, setMode] = useState<PermissionMode>(initial ?? "default");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const read = useCallback(
|
||||
() =>
|
||||
api.run
|
||||
.permissionMode(runId)
|
||||
.then((r) => isSettable(r.mode) && setMode(r.mode))
|
||||
.catch(() => {}),
|
||||
[runId]
|
||||
);
|
||||
useEffect(() => {
|
||||
void read();
|
||||
}, [read]);
|
||||
// Bypass is in a session's cycle only when it was started with it.
|
||||
const offered = MODES.filter(([v]) => v !== "bypassPermissions" || initial === v || mode === v);
|
||||
return (
|
||||
<div data-testid="pane-chat-mode">
|
||||
<Select<PermissionMode>
|
||||
variant="ghost"
|
||||
label={t("run:fields.permissionMode")}
|
||||
value={mode}
|
||||
disabled={busy}
|
||||
onChange={(v) => {
|
||||
setBusy(true);
|
||||
api.run
|
||||
.setPermissionMode(runId, v)
|
||||
.then((r) => setMode(r.mode))
|
||||
.catch((err: unknown) => {
|
||||
toast.error(
|
||||
t("run:chat.modeFailed", { message: err instanceof Error ? err.message : "" })
|
||||
);
|
||||
void read();
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}}
|
||||
options={offered.map(([value, key]) => ({
|
||||
value,
|
||||
label: t(`lanes:home.permission${key}`),
|
||||
hint: t(`run:fields.permission${key}`),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneChat({
|
||||
runId,
|
||||
run,
|
||||
sessionId,
|
||||
tool,
|
||||
cwd,
|
||||
laneId = null,
|
||||
laneCwd = "",
|
||||
outline = false,
|
||||
}: {
|
||||
runId: string;
|
||||
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
|
||||
sessionId: string;
|
||||
tool: string;
|
||||
cwd: string;
|
||||
/** The workspace and its folder: a file under `cwd` is previewed through it. */
|
||||
laneId?: number | null;
|
||||
laneCwd?: string;
|
||||
/** The pane is shown alone: room for the right panel. */
|
||||
outline?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const runId = run.id;
|
||||
const claude = run.tool === "claude";
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [follow, setFollow] = useState(0);
|
||||
const [model, setModel] = useState(run.model ?? "");
|
||||
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
||||
const pickFiles = useRef<(() => void) | null>(null);
|
||||
// Collapsed until asked for, like Claude Desktop's activity panel.
|
||||
const [outlineOpen, setOutlineOpen] = useState(() => localStorage.getItem(OUTLINE_KEY) === "1");
|
||||
const openOutline = (open: boolean) => {
|
||||
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
|
||||
setOutlineOpen(open);
|
||||
setPreview(null);
|
||||
};
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [list, setList] = useState<OutlineList | null>(null);
|
||||
const activity = useSessionOutline(sessionId);
|
||||
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||
const text = promptWithImages(prompt, images);
|
||||
|
||||
// The main agent is "working" from a prompt's submit to its Stop hook.
|
||||
const [working, setWorking] = useState(false);
|
||||
const readWorking = useCallback(
|
||||
() =>
|
||||
api.agents
|
||||
.list({ session_id: sessionId, status: "working" })
|
||||
// With session_id the server answers every agent of the session, its
|
||||
// status filter ignored: check the status here.
|
||||
.then((r) => setWorking(r.agents.some((a) => a.type === "main" && a.status === "working")))
|
||||
.catch(() => {}),
|
||||
[sessionId]
|
||||
);
|
||||
useEffect(() => {
|
||||
void readWorking();
|
||||
}, [readWorking]);
|
||||
useLiveRefresh(readWorking, {
|
||||
throttleMs: 500,
|
||||
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
||||
// another dashboard on the same database, or the socket dropped) would
|
||||
// otherwise leave Stop up after the turn ended.
|
||||
intervalMs: working ? 5000 : undefined,
|
||||
match: (msg) =>
|
||||
msg.type === "agent_updated" &&
|
||||
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
||||
});
|
||||
|
||||
// A control is a command typed into the pane, like the user would.
|
||||
const command = (line: string) => {
|
||||
const ok = submitToRun(runId, line);
|
||||
if (!ok) toast.error(t("chat.notSent"));
|
||||
return ok;
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (!text || uploading) return;
|
||||
if (!submitToRun(runId, text)) {
|
||||
toast.error(t("chat.notSent"));
|
||||
return;
|
||||
}
|
||||
if (!command(text)) return;
|
||||
setFollow((n) => n + 1);
|
||||
setPrompt("");
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="pane-chat" className="flex min-h-0 flex-1 flex-col gap-2 pb-3">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} fill />
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={send}
|
||||
placeholder={t("chat.placeholder")}
|
||||
rows={2}
|
||||
slash={tool === "claude"}
|
||||
fileCwd={cwd}
|
||||
lane={runId}
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView
|
||||
sessionId={sessionId}
|
||||
fill
|
||||
live
|
||||
follow={follow}
|
||||
onOpenFile={(path) => {
|
||||
// The workspace's Changes panel when the file is in its folder;
|
||||
// else this view's own preview, when it has room for one.
|
||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||
if (outline) setPreview(path);
|
||||
}}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
|
||||
{outline && !outlineOpen && !preview && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-send"
|
||||
onClick={send}
|
||||
disabled={uploading || !text}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
data-testid="chat-outline-show"
|
||||
onClick={() => openOutline(true)}
|
||||
title={t("chat.outline.show")}
|
||||
aria-label={t("chat.outline.show")}
|
||||
className="absolute right-3 top-2 hidden h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary lg:flex"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{activity.outputs.length}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
|
||||
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
pickFiles={pickFiles}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={send}
|
||||
placeholder={t("chat.placeholder")}
|
||||
rows={2}
|
||||
slash={claude}
|
||||
fileCwd={cwd}
|
||||
lane={runId}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<ChatAddMenu
|
||||
cwd={cwd}
|
||||
laneId={laneId}
|
||||
skills={claude}
|
||||
onUpload={() => pickFiles.current?.()}
|
||||
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
|
||||
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
||||
/>
|
||||
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{claude && (
|
||||
<ModelMenu
|
||||
value={model}
|
||||
onChange={(v) => {
|
||||
if (command(`/model ${v || "default"}`)) setModel(v);
|
||||
}}
|
||||
tool={run.tool}
|
||||
effort={effort}
|
||||
onEffortChange={(e) => {
|
||||
if (command(`/effort ${e || "auto"}`)) setEffort(e);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{working && !text ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-stop"
|
||||
onClick={() => {
|
||||
// An interrupted turn fires no Stop hook: clear it here.
|
||||
if (interruptRun(runId)) setWorking(false);
|
||||
else toast.error(t("chat.notStopped"));
|
||||
}}
|
||||
aria-label={t("chat.stop")}
|
||||
title={t("chat.stop")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-send"
|
||||
onClick={send}
|
||||
disabled={uploading || !text}
|
||||
aria-label={t("chat.send")}
|
||||
title={t("chat.send")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{outline &&
|
||||
(preview ? (
|
||||
<FilePreview
|
||||
path={preview}
|
||||
scope={scope}
|
||||
onClose={() => setPreview(null)}
|
||||
// Beside the chat when there is room, over it when not.
|
||||
className="absolute inset-0 z-30 rounded-xl border border-border lg:static lg:m-3 lg:ml-0 lg:w-[45%] lg:max-w-3xl lg:flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
outlineOpen && (
|
||||
<ChatOutline
|
||||
outline={activity}
|
||||
onClose={() => openOutline(false)}
|
||||
onOpenFile={setPreview}
|
||||
onOpenList={setList}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{list && (
|
||||
<FileListModal
|
||||
title={t(`chat.outline.${list}`)}
|
||||
paths={activity[list]}
|
||||
scope={scope}
|
||||
onClose={() => setList(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user