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:
@@ -3,12 +3,13 @@
|
||||
* @description Pins where a pasted diff comment lands: the workspace's pane
|
||||
* focused last, else any of its panes, never another workspace's (lane 1 is
|
||||
* not lane 12), nowhere once the pane unmounted, and never into a pane that
|
||||
* is not on screen (a hidden console behind a full-width Changes panel).
|
||||
* is not on screen (a hidden console behind a full-width Changes panel); and
|
||||
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { notePaneFocus, pasteToLane, registerPane } from "../panePaste";
|
||||
import { interruptRun, notePaneFocus, pasteToLane, registerPane } from "../panePaste";
|
||||
|
||||
describe("panePaste", () => {
|
||||
it("pastes into the workspace's last-focused pane, and only its own", () => {
|
||||
@@ -48,4 +49,20 @@ describe("panePaste", () => {
|
||||
expect(got).toEqual(["y"]);
|
||||
off();
|
||||
});
|
||||
|
||||
it("interruptRun presses Esc in a visible pane only", () => {
|
||||
const keys: string[] = [];
|
||||
let shown = false;
|
||||
const off = registerPane(
|
||||
"agenthub-lane-6",
|
||||
() => {},
|
||||
() => shown,
|
||||
(d) => keys.push(d)
|
||||
);
|
||||
expect(interruptRun("agenthub-lane-6")).toBe(false);
|
||||
shown = true;
|
||||
expect(interruptRun("agenthub-lane-6")).toBe(true);
|
||||
expect(keys).toEqual(["\x1b"]);
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
+50
-8
@@ -644,22 +644,30 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
*/
|
||||
/**
|
||||
* What a workspace git call works on: the workspace (its id) or, with
|
||||
* `session`, that session's own worktree (`claude --worktree`) — the server
|
||||
* resolves the tree from the session row (`?session=`), never from a path.
|
||||
* `session`, that session's own worktree (`claude --worktree`), or, with
|
||||
* `source`, one of the workspace's extra folders — the server resolves the
|
||||
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
|
||||
* never from a path.
|
||||
*/
|
||||
export type GitTarget = number | { lane: number; session?: string };
|
||||
export type GitTarget = number | { lane: number; session?: string; source?: number };
|
||||
|
||||
/** `/lanes/<id>/git<sub>` plus `q` and, for a session target, `session=`. */
|
||||
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
|
||||
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||
const params = new URLSearchParams(q);
|
||||
const lane = typeof target === "number" ? target : target.lane;
|
||||
if (typeof target !== "number" && target.session) params.set("session", target.session);
|
||||
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
|
||||
const qs = params.toString();
|
||||
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
/** The target for `laneId`, in `session`'s worktree when one is given. */
|
||||
export function gitTarget(laneId: number, session?: string | null): GitTarget {
|
||||
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
|
||||
export function gitTarget(
|
||||
laneId: number,
|
||||
session?: string | null,
|
||||
source?: number | null
|
||||
): GitTarget {
|
||||
if (source) return { lane: laneId, source };
|
||||
return session ? { lane: laneId, session } : laneId;
|
||||
}
|
||||
|
||||
@@ -911,6 +919,9 @@ export const api = {
|
||||
* @returns {@link SessionStats} for that one session.
|
||||
*/
|
||||
stats: (id: string) => request<SessionStats>(`/sessions/${encodeURIComponent(id)}/stats`),
|
||||
/** GET /api/sessions/:id/outline — the session's plan, the files it wrote
|
||||
* and the skills / MCP servers it used (a Chat pane's side panel). */
|
||||
outline: (id: string) => request<SessionOutline>(`/sessions/${encodeURIComponent(id)}/outline`),
|
||||
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
||||
* workspace, deletes nothing). */
|
||||
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
||||
@@ -2437,10 +2448,12 @@ export const api = {
|
||||
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
|
||||
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
|
||||
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
|
||||
/** GET /api/run/files - path-completion suggestions under `cwd`. */
|
||||
files: (cwd: string, q?: string) => {
|
||||
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
|
||||
* adds the folders too, as `dir/`. */
|
||||
files: (cwd: string, q?: string, dirs = false) => {
|
||||
const qs = new URLSearchParams({ cwd });
|
||||
if (q) qs.set("q", q);
|
||||
if (dirs) qs.set("dirs", "1");
|
||||
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
|
||||
},
|
||||
/** POST /api/run/drop-image - save an image dropped on the terminal on the
|
||||
@@ -2470,6 +2483,19 @@ export const api = {
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
/** GET /api/run/:id - one run's current handle. */
|
||||
get: (id: string) => request<RunHandle>(`/run/${encodeURIComponent(id)}`),
|
||||
/** GET /api/run/:id/permission-mode - the mode a live claude pane shows now
|
||||
* (null while a menu hides it). */
|
||||
permissionMode: (id: string) =>
|
||||
request<{ mode: PermissionMode | "dontAsk" | null }>(
|
||||
`/run/${encodeURIComponent(id)}/permission-mode`
|
||||
),
|
||||
/** POST /api/run/:id/permission-mode - step the pane's mode (Shift+Tab, each
|
||||
* step read back) to `mode`; 409 when it cannot be reached. */
|
||||
setPermissionMode: (id: string, mode: PermissionMode) =>
|
||||
request<{ mode: PermissionMode }>(`/run/${encodeURIComponent(id)}/permission-mode`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
/** DELETE /api/run/:id - kill the tmux session. */
|
||||
kill: (id: string) =>
|
||||
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
@@ -4294,6 +4320,22 @@ export interface CodexMarketplaceActionArgs {
|
||||
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */
|
||||
export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions";
|
||||
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
|
||||
/** `GET /api/sessions/:id/outline`. `status` is the agent's own word
|
||||
* (`pending` / `in_progress` / `completed`). */
|
||||
export interface SessionOutline {
|
||||
progress: { id: string; subject: string; status: string }[];
|
||||
/** Files it wrote or edited, newest first. */
|
||||
outputs: string[];
|
||||
/** Files it only read, newest first. */
|
||||
files: string[];
|
||||
/** Files attached to its prompts (the composer's drop dir), newest first. */
|
||||
uploads: string[];
|
||||
/** Claude Code memory files it read or wrote, newest first. */
|
||||
memory: string[];
|
||||
skills: string[];
|
||||
mcp: string[];
|
||||
}
|
||||
|
||||
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||
/** "running" — a live tmux session exists; "gone" — it doesn't (killed,
|
||||
* crashed, claude exited and closed the pane). Computed fresh from tmux
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @file brand.ts
|
||||
* @description SmartGift logo URLs shared by the sidebar and the login page.
|
||||
* Remote URLs: the logo is owned by the brand, not vendored here — a machine
|
||||
* with no network shows the alt text instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const BRAND_LOGO =
|
||||
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
|
||||
export const BRAND_MARK = "https://smartgift.vn/favicon.ico";
|
||||
+64
-15
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* @file chatUi.ts — the viewer's Chat UI choice: whether a Claude/Codex pane
|
||||
* opens as a chat (`PaneChat`, like Claude Desktop) instead of its terminal.
|
||||
* Kept by the server per ACCOUNT (`GET/PUT /api/me/prefs`, key
|
||||
* `agenthub.chatUi` = "1"; local mode is one account), so it follows a user to
|
||||
* any browser and differs between users of one dashboard. 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.
|
||||
* @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>
|
||||
*/
|
||||
|
||||
@@ -14,21 +18,44 @@ 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) => emit(r.prefs[CHAT_UI_KEY] === "1"),
|
||||
(r) => {
|
||||
loaded = true;
|
||||
emit(r.prefs[CHAT_UI_KEY] === "1");
|
||||
},
|
||||
() => {
|
||||
loading = null; // the next reader asks again
|
||||
}
|
||||
@@ -37,13 +64,32 @@ function load() {
|
||||
|
||||
export function useChatUi(): boolean {
|
||||
useEffect(load, []);
|
||||
return useSyncExternalStore(
|
||||
(fn) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
() => chatUi
|
||||
);
|
||||
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> {
|
||||
@@ -61,5 +107,8 @@ export async function saveChatUi(on: boolean): Promise<void> {
|
||||
/** Tests: forget what was read. */
|
||||
export function __resetChatUi() {
|
||||
chatUi = false;
|
||||
loaded = false;
|
||||
loading = null;
|
||||
views = {};
|
||||
localStorage.removeItem(VIEWS_KEY);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @file openInChanges.ts — asks the Workspace page to open a file in its
|
||||
* Changes panel: a chat's "Edited N files" card names absolute paths deep in
|
||||
* a pane, the panel lives on the page, so the request travels as a window
|
||||
* event (like the sidebar's `LIVE_RUNS_EVENT`). The page opens the panel and
|
||||
* that file there, if the panel shows the same workspace; else nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const OPEN_IN_CHANGES_EVENT = "agenthub:open-in-changes";
|
||||
|
||||
/** A file to open: its workspace, and its path relative to the panel's folder. */
|
||||
export type OpenInChanges = { laneId: number; path: string };
|
||||
|
||||
/** Opens `path` (absolute) when it lies under `cwd`, the pane's folder — the
|
||||
* folder the Changes panel reads for it. False when it does not. */
|
||||
export function openInChanges(laneId: number, cwd: string, path: string): boolean {
|
||||
const base = cwd.replace(/\/+$/, "");
|
||||
if (!base || !path.startsWith(`${base}/`)) return false;
|
||||
const detail: OpenInChanges = { laneId, path: path.slice(base.length + 1) };
|
||||
window.dispatchEvent(new CustomEvent(OPEN_IN_CHANGES_EVENT, { detail }));
|
||||
return true;
|
||||
}
|
||||
@@ -9,25 +9,28 @@
|
||||
* hidden behind a full-width Changes panel or a replay would swallow it
|
||||
* unseen); the caller says where it went, or that it went nowhere. The one
|
||||
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
|
||||
* what the user typed there, so it pastes AND presses Enter.
|
||||
* what the user typed there, so it pastes AND presses Enter — and its Stop
|
||||
* button presses Esc through `interruptRun`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
interface Pane {
|
||||
paste: (text: string, submit?: boolean) => void;
|
||||
visible: () => boolean;
|
||||
key?: (data: string) => void;
|
||||
}
|
||||
const panes = new Map<string, Pane>();
|
||||
let lastFocused: string | null = null;
|
||||
|
||||
/** Registers a mounted terminal (`visible`: is it on screen now); returns its
|
||||
* unregister. */
|
||||
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
|
||||
* raw input, as a key press); returns its unregister. */
|
||||
export function registerPane(
|
||||
runId: string,
|
||||
paste: (text: string, submit?: boolean) => void,
|
||||
visible: () => boolean = () => true
|
||||
visible: () => boolean = () => true,
|
||||
key?: (data: string) => void
|
||||
): () => void {
|
||||
const entry = { paste, visible };
|
||||
const entry = { paste, visible, key };
|
||||
panes.set(runId, entry);
|
||||
return () => {
|
||||
if (panes.get(runId) === entry) panes.delete(runId);
|
||||
@@ -63,6 +66,15 @@ export function submitToRun(runId: string, text: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Presses Esc in the pane of `runId` — how `claude` and `codex` stop the turn
|
||||
* they are running. False when the pane is not mounted or not attached. */
|
||||
export function interruptRun(runId: string): boolean {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible() || !p.key) return false;
|
||||
p.key("\x1b");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
|
||||
* last when it is — and returns its run id; null when none is on screen. */
|
||||
export function pasteToLane(laneId: number, text: string): string | null {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
|
||||
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
|
||||
* expanded to their sessions (by lane id), the tool the list is filtered to,
|
||||
* and — Settings → Worktrees —
|
||||
* which owner groups an admin/analyst folded, and — Settings → Worktrees —
|
||||
* whether a workspace home's Add folder suggests the repo's untracked
|
||||
* worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`,
|
||||
* `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing
|
||||
@@ -20,6 +20,8 @@ export interface ProjectTreePrefs {
|
||||
expanded: Record<string, boolean>;
|
||||
/** Show only this tool's workspaces; null = every tool. */
|
||||
tool: string | null;
|
||||
/** Server mode, admin/analyst: owner key (user id, or "none") → its group is folded. */
|
||||
collapsedOwners: Record<string, boolean>;
|
||||
showUntracked: boolean;
|
||||
hideSources: string[];
|
||||
}
|
||||
@@ -27,6 +29,7 @@ export interface ProjectTreePrefs {
|
||||
const DEFAULTS: ProjectTreePrefs = {
|
||||
expanded: {},
|
||||
tool: null,
|
||||
collapsedOwners: {},
|
||||
showUntracked: true,
|
||||
hideSources: [],
|
||||
};
|
||||
@@ -50,6 +53,10 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
|
||||
value = {
|
||||
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
|
||||
tool: typeof parsed.tool === "string" && parsed.tool ? parsed.tool : null,
|
||||
collapsedOwners:
|
||||
parsed.collapsedOwners && typeof parsed.collapsedOwners === "object"
|
||||
? parsed.collapsedOwners
|
||||
: {},
|
||||
showUntracked: parsed.showUntracked !== false,
|
||||
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @file promptAttach.ts
|
||||
* @description What a prompt box can attach besides a fresh upload: a project
|
||||
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
|
||||
* `PromptEditor`'s drop) and the files uploaded through a prompt box in the
|
||||
* last day (`recentUploads`, kept in this browser). An attachment is a path
|
||||
* sent with the prompt, so nothing here touches the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { DragEvent } from "react";
|
||||
|
||||
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
|
||||
export interface Attachment {
|
||||
path: string;
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export const ATTACH_MIME = "application/x-agenthub-attach";
|
||||
|
||||
/** A project path (relative to the box's folder) as Claude Code's `@` mention. */
|
||||
export function projectAttachment(rel: string): Attachment {
|
||||
const name = rel.replace(/\/$/, "").split("/").pop() || rel;
|
||||
return { path: `@${rel}`, name: rel.endsWith("/") ? `${name}/` : name };
|
||||
}
|
||||
|
||||
/** Start a drag carrying `rel` (a Files panel row) to a prompt box; plain
|
||||
* text too, so a drop elsewhere (a terminal, an editor) gets the path. */
|
||||
export function dragProjectPath(e: DragEvent, rel: string) {
|
||||
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(projectAttachment(rel)));
|
||||
e.dataTransfer.setData("text/plain", rel);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
|
||||
/** The attachment a drop carries, if it is one of ours. */
|
||||
export function droppedAttachment(dt: DataTransfer | null | undefined): Attachment | null {
|
||||
try {
|
||||
const v: unknown = JSON.parse(dt?.getData(ATTACH_MIME) || "null");
|
||||
const a = v as Attachment | null;
|
||||
return a && typeof a.path === "string" && typeof a.name === "string"
|
||||
? { path: a.path, name: a.name }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `prev` plus whichever of `items` it does not hold yet. */
|
||||
export function addAttachments<T extends Attachment>(prev: T[], items: T[]): T[] {
|
||||
const fresh = items.filter(
|
||||
(a, i) =>
|
||||
!prev.some((p) => p.path === a.path) && items.findIndex((b) => b.path === a.path) === i
|
||||
);
|
||||
return fresh.length ? [...prev, ...fresh] : prev;
|
||||
}
|
||||
|
||||
const RECENT_KEY = "agenthub.prompt.recentUploads";
|
||||
const RECENT_MAX = 20;
|
||||
// The server prunes an upload a day after it was written (`saveDrop`).
|
||||
// ponytail: a client-side guess of that TTL, a few minutes short; a listing
|
||||
// route instead if uploads must outlive it or follow the user to another browser.
|
||||
const RECENT_TTL_MS = 23.9 * 60 * 60_000;
|
||||
|
||||
interface Recent extends Attachment {
|
||||
at: number;
|
||||
}
|
||||
|
||||
function readRecent(): Recent[] {
|
||||
try {
|
||||
const v: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
|
||||
const cutoff = Date.now() - RECENT_TTL_MS;
|
||||
return (Array.isArray(v) ? (v as Recent[]) : []).filter(
|
||||
(r) => typeof r?.path === "string" && typeof r.name === "string" && r.at > cutoff
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function recentUploads(): Attachment[] {
|
||||
return readRecent().map(({ path, name, url }) => ({ path, name, ...(url ? { url } : {}) }));
|
||||
}
|
||||
|
||||
/** Remember uploads just saved, newest first; an image keeps a thumbnail
|
||||
* through `GET /api/run/drop/:name` (its local blob URL dies with the page). */
|
||||
export function rememberUploads(items: { path: string; name: string; image: boolean }[]) {
|
||||
const at = Date.now();
|
||||
const added: Recent[] = items.map(({ path, name, image }) => ({
|
||||
path,
|
||||
name,
|
||||
at,
|
||||
...(image ? { url: `/api/run/drop/${encodeURIComponent(path.split("/").pop() || "")}` } : {}),
|
||||
}));
|
||||
const kept = readRecent().filter((r) => !added.some((a) => a.path === r.path));
|
||||
try {
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify([...added, ...kept].slice(0, RECENT_MAX)));
|
||||
} catch {
|
||||
/* storage full or blocked: the list is a convenience */
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file terminalFont — the lane terminal's font, a viewer preference kept in
|
||||
* `localStorage` (`agenthub.terminal.font`) and read by `TerminalView` when a
|
||||
* terminal opens. `jetbrains` (the default) and `maple` (rounded) are bundled
|
||||
* webfonts that cover every Vietnamese letter; `system` is the browser's own
|
||||
* terminal opens. `jetbrains` (the default) and the rounded `maple` are
|
||||
* bundled webfonts that cover every Vietnamese letter; `system` is the browser's own
|
||||
* fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…,
|
||||
* and those glyphs then fall back to a face of another width).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
|
||||
@@ -2661,6 +2661,8 @@ export interface Lane {
|
||||
cwd: string;
|
||||
/** Server mode: the account that owns this workspace (null = nobody yet). */
|
||||
owner_id?: number | null;
|
||||
/** The owner's username (`users.username`); null with no owner. */
|
||||
owner_name?: string | null;
|
||||
branch: string | null;
|
||||
kind: "managed" | "adopted";
|
||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||
@@ -3205,6 +3207,9 @@ export interface TranscriptContent {
|
||||
output?: string;
|
||||
/** Present for "tool_result" blocks: whether the tool call errored. */
|
||||
is_error?: boolean;
|
||||
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
|
||||
* removed (from Claude Code's `structuredPatch`). */
|
||||
diff?: { added: number; removed: number };
|
||||
}
|
||||
|
||||
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
|
||||
@@ -3239,6 +3244,9 @@ export interface TranscriptMessage {
|
||||
* Example: `"claude-opus-4-8"`. */
|
||||
model?: string;
|
||||
/** Token accounting reported alongside an assistant message; absent otherwise. */
|
||||
/** Assistant lines: why the model stopped — "end_turn" closes the turn,
|
||||
* "tool_use" means more work follows. Absent from an older server. */
|
||||
stop_reason?: string;
|
||||
usage?: {
|
||||
/** Fresh (non-cached) input tokens for this message. */
|
||||
input_tokens: number;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api } from "./api";
|
||||
import i18n from "../i18n";
|
||||
import { applyStoredTheme } from "../hooks/useTheme";
|
||||
|
||||
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
||||
export const PREF_KEYS = [
|
||||
@@ -100,6 +101,8 @@ function readLocal(key: string): string | null {
|
||||
|
||||
/** Make what reads these keys read them again. */
|
||||
function reapply(prefs: Record<string, string>) {
|
||||
// Painted now, not only on the next useTheme render: the login page wore the OS's.
|
||||
applyStoredTheme();
|
||||
window.dispatchEvent(new Event("agenthub-theme"));
|
||||
window.dispatchEvent(new Event("sagi:prefs"));
|
||||
const lng = prefs.i18nextLng;
|
||||
|
||||
Reference in New Issue
Block a user