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:
2026-10-09 15:51:19 +07:00
parent ce773a3f5a
commit fa156d6a59
114 changed files with 5831 additions and 885 deletions
+92 -31
View File
@@ -6,8 +6,12 @@
* box (`WorkspaceHome.tsx`):
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
* newlines, and pasted or dropped files (images as thumbnails, any other
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
* + menu; images as thumbnails, opened full size in `ImageLightbox`, any
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
* remembered for the + menu's Recent uploads), and a Files panel row
* dropped on it (`lib/promptAttach.ts`) as an `@path` chip;
* a borderless (`bare`) one grows with its text up to 40vh;
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
* or a fixed one (an AI Proxy's models), plus Custom…;
@@ -19,7 +23,17 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from "react";
import { useTranslation } from "react-i18next";
import {
Check,
@@ -42,9 +56,16 @@ import {
} from "../../lib/api";
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
import type { LaneTool } from "../../lib/types";
import { Select } from "../Select";
import { Select, menuRoom } from "../Select";
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
import { isSubmitEnter } from "../../lib/keys";
import { ImageLightbox } from "../ImageLightbox";
import {
ATTACH_MIME,
addAttachments,
droppedAttachment,
rememberUploads,
} from "../../lib/promptAttach";
export interface SlashCommand {
name: string;
@@ -158,13 +179,16 @@ interface PromptEditorProps {
* sends them with the text through `promptWithImages`. */
images: PromptImage[];
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
/** Borderless, for a composer box that draws its own frame. */
/** Borderless, for a composer box that draws its own frame — and grows
* with its text, from `rows` up to 40% of the screen, then scrolls. */
bare?: boolean;
/** An image upload started (true) or settled (false): the caller holds its
* send button meanwhile, or the prompt would go out without the image. */
onBusyChange?: (busy: boolean) => void;
/** The workspace (id, or a pane's run id) whose panes read the files. */
lane?: number | string;
/** Set to a function that opens a file picker (a composer's + menu). */
pickFiles?: MutableRefObject<(() => void) | null>;
}
/** An attached file: its path on the server's machine, and a local preview
@@ -177,6 +201,9 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
}
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
export const PASTE_AS_FILE_CHARS = 5000;
export function PromptEditor({
value,
onChange,
@@ -190,9 +217,18 @@ export function PromptEditor({
bare,
onBusyChange,
lane,
pickFiles,
}: PromptEditorProps) {
const { t } = useTranslation("run");
const taRef = useRef<HTMLTextAreaElement | null>(null);
// A composer grows with its text: reset to its rows, then take what the
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
useLayoutEffect(() => {
const ta = taRef.current;
if (!bare || !ta) return;
ta.style.height = "auto";
ta.style.height = `${ta.scrollHeight}px`;
}, [bare, value]);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [active, setActive] = useState(0);
const [files, setFiles] = useState<string[]>([]);
@@ -324,7 +360,7 @@ export function PromptEditor({
const busyRef = useRef(onBusyChange);
busyRef.current = onBusyChange;
useEffect(() => busyRef.current?.(uploading), [uploading]);
const attachImages = (list: FileList | null | undefined) => {
const attachImages = (list: FileList | File[] | null | undefined) => {
const picked = Array.from(list ?? []);
if (picked.length === 0) return false;
setUpload("uploading");
@@ -336,6 +372,7 @@ export function PromptEditor({
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
name: f.name,
}));
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
onImagesChange((prev) => [...prev, ...added]);
})
.catch(() => setUpload("failed"));
@@ -345,12 +382,11 @@ export function PromptEditor({
if (img.url) URL.revokeObjectURL(img.url);
onImagesChange((prev) => prev.filter((x) => x !== img));
};
useEffect(() => {
if (!viewing) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setViewing(null);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [viewing]);
const closeViewer = useCallback(() => setViewing(null), []);
// The composer's own "Upload or add files" opens this picker; what it picks
// takes the same path as a paste or a drop.
const fileInput = useRef<HTMLInputElement | null>(null);
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (kind && count > 0) {
@@ -454,6 +490,19 @@ export function PromptEditor({
)}
</div>
)}
{pickFiles && (
<input
ref={fileInput}
type="file"
multiple
hidden
data-testid="prompt-file-input"
onChange={(e) => {
attachImages(e.target.files);
e.target.value = "";
}}
/>
)}
<textarea
ref={taRef}
value={value}
@@ -467,8 +516,28 @@ export function PromptEditor({
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault();
else {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? "";
if (text.length > PASTE_AS_FILE_CHARS) {
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
}
}}
onDragOver={(e) => {
// A Files panel row is not a file: say a drop is welcome.
if (e.dataTransfer?.types.includes(ATTACH_MIME)) e.preventDefault();
}}
onDrop={(e) => {
const att = droppedAttachment(e.dataTransfer);
if (att) {
e.preventDefault();
onImagesChange((prev) => addAttachments(prev, [att]));
return;
}
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
}}
placeholder={placeholder}
@@ -476,7 +545,7 @@ export function PromptEditor({
spellCheck={false}
className={
bare
? "block w-full resize-none bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
}
/>
@@ -545,20 +614,7 @@ export function PromptEditor({
)}
</div>
)}
{viewing && (
<div
role="dialog"
aria-label={viewing.name}
onClick={() => setViewing(null)}
className="fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
>
<img
src={viewing.url}
alt={viewing.name}
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
/>
</div>
)}
{viewing?.url && <ImageLightbox src={viewing.url} alt={viewing.name} onClose={closeViewer} />}
</div>
);
}
@@ -673,7 +729,10 @@ export function CwdAutocomplete({
/>
</div>
{open && (
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
<div
data-cwd-suggestions
className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1"
>
{groups.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div>
) : (
@@ -856,9 +915,11 @@ export function ModelMenu({
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
// Below the chip unless the viewport has more room above it.
const r = ref.current?.getBoundingClientRect();
if (r) setUp(window.innerHeight - r.bottom < 360 && r.top > window.innerHeight - r.bottom);
// Below the chip unless its pane (or the viewport) has more room above it.
if (ref.current) {
const { above, below } = menuRoom(ref.current, ref.current.getBoundingClientRect());
setUp(below < 360 && above > below);
}
const away = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};