383b9ae231
- The chat over a terminal pane now covers Claude, Codex, omp, Cursor and Antigravity: Cursor's agent transcript and agy's step log (brain/<id>/.system_generated/logs/transcript_full.jsonl) are read as conversations; Cursor sessions are claimed by their workspace - Replies appear as soon as they are written: the projects-tree watcher broadcasts `transcript_changed` (at most every 250 ms per session) and the chat re-reads the transcript while the terminal underneath draws - Stop shows from the pane's own busy line (Claude/Codex `esc to interrupt`, agy `… esc to cancel`), or fresh output for omp/Cursor — no hooks needed - Permission prompts read off the terminal become a card with the tool, its command and "Deny with a note"; Codex gets Permission mode / Model buttons that open its own numbered pickers, answered from the menu card - Artifacts: cards for pages/documents/diagrams/tables/React components, a Preview | Code panel with versions, copy, download, save to the Artifacts Hub, full screen; sandboxed iframes without allow-same-origin - Claude panes get CLAUDE_CODE_ENABLE_TODO_TOOLS=1 so the Progress panel has a plan; multi-question pane menus read their Next row and question tabs - The realtime chat bridge (structured stdio, /api/chat-bridge, /ws-chat) from the unreleased ad9d475 is not kept
129 lines
4.2 KiB
TypeScript
129 lines
4.2 KiB
TypeScript
/**
|
|
* @file chatUi.ts — which tools' terminal panes have a Chat view
|
|
* (`CHAT_TOOLS`), and whether such a 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";
|
|
|
|
/** The tools whose terminal pane can show a chat: each one's pane learns its
|
|
* session id (Claude's `--session-id`, the others' pollers through
|
|
* `claimToolSessions`) and the server reads that session's conversation
|
|
* (`GET /api/sessions/:id/transcript`). omp and Cursor send no hooks, so
|
|
* their chat has no Stop button and follows at the pollers' pace. */
|
|
export const CHAT_TOOLS: ReadonlySet<string> = new Set([
|
|
"claude",
|
|
"codex",
|
|
"omp",
|
|
"cursor",
|
|
"antigravity",
|
|
]);
|
|
|
|
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);
|
|
}
|