Files
AgentHub/client/src/lib/chatUi.ts
T
nntrivi2001 fa156d6a59 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.
2026-10-09 16:35:07 +07:00

115 lines
3.6 KiB
TypeScript

/**
* @file chatUi.ts — whether a Claude/Codex session shows as a chat
* (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its
* own view (`useSessionChat`, toggled by `setSessionChat` from the pane's
* chat/terminal button), kept in this browser's localStorage
* (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI
* setting is only the DEFAULT: kept by the server per account
* (`GET/PUT /api/me/prefs`, key `agenthub.chatUi` = "1"; local mode is one
* account), it is written onto a session the first time that session is
* shown, so changing it later leaves every session already seen alone. Read
* once per page load and shared through `useChatUi`; `saveChatUi` updates
* every reader at once and puts the old value back if the save fails.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useSyncExternalStore } from "react";
import { api } from "./api";
export const CHAT_UI_KEY = "agenthub.chatUi";
export const VIEWS_KEY = "agenthub.chatUi.sessions";
const MAX_VIEWS = 200;
let chatUi = false;
let loaded = false;
let loading: Promise<void> | null = null;
const listeners = new Set<() => void>();
const subscribe = (fn: () => void) => {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
};
const emit = (next: boolean) => {
chatUi = next;
listeners.forEach((fn) => fn());
};
function readViews(): Record<string, boolean> {
try {
const v: unknown = JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}");
return v && typeof v === "object" ? (v as Record<string, boolean>) : {};
} catch {
return {};
}
}
let views = readViews();
function load() {
// A failed read (logged out, offline) must never take a page down with it.
loading ??= Promise.resolve()
.then(() => api.me.prefs())
.then(
(r) => {
loaded = true;
emit(r.prefs[CHAT_UI_KEY] === "1");
},
() => {
loading = null; // the next reader asks again
}
);
}
export function useChatUi(): boolean {
useEffect(load, []);
return useSyncExternalStore(subscribe, () => chatUi);
}
/** Shows `sessionId` as a chat (`true`) or its terminal, for this browser. */
export function setSessionChat(sessionId: string, on: boolean) {
const { [sessionId]: _old, ...rest } = views;
// Newest last, so the cap drops the sessions touched longest ago.
views = Object.fromEntries(Object.entries({ ...rest, [sessionId]: on }).slice(-MAX_VIEWS));
try {
localStorage.setItem(VIEWS_KEY, JSON.stringify(views));
} catch {
// Storage full or blocked: the choice still holds for this page load.
}
listeners.forEach((fn) => fn());
}
/** The session's own view; a session seen for the first time takes the
* account's default (once it is read) and keeps it. */
export function useSessionChat(sessionId: string | null | undefined): boolean {
const fallback = useChatUi();
const own = useSyncExternalStore(subscribe, () => (sessionId ? views[sessionId] : undefined));
const ready = useSyncExternalStore(subscribe, () => loaded);
useEffect(() => {
if (sessionId && own === undefined && ready) setSessionChat(sessionId, chatUi);
}, [sessionId, own, ready]);
return own ?? fallback;
}
export async function saveChatUi(on: boolean): Promise<void> {
const before = chatUi;
emit(on);
try {
const r = await api.me.savePrefs({ [CHAT_UI_KEY]: on ? "1" : null });
emit(r.prefs[CHAT_UI_KEY] === "1");
} catch (err) {
emit(before);
throw err;
}
}
/** Tests: forget what was read. */
export function __resetChatUi() {
chatUi = false;
loaded = false;
loading = null;
views = {};
localStorage.removeItem(VIEWS_KEY);
}