feat: file upload/download and pinned sessions, text size, git identity and clone

Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):

- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
  /:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
  (git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
  the session menu, pinned first); text size setting (lib/fontSize.ts,
  Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
  routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().

Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 12:21:55 +07:00
parent 0cb5e41faf
commit cb19d7e5d2
63 changed files with 3833 additions and 209 deletions
+77 -1
View File
@@ -987,6 +987,8 @@ export const api = {
lane?: number;
/** `false` = not archived, `true` = archived only; omitted = both. */
archived?: boolean;
/** Pinned sessions first (by pin time), before the limit. */
pinnedFirst?: boolean;
/** Only active sessions waiting on the user (`awaiting=1`). */
awaiting?: boolean;
/** ISO bounds on `started_at`. */
@@ -1005,6 +1007,7 @@ export const api = {
if (params?.tool) qs.set("tool", params.tool);
if (params?.lane !== undefined) qs.set("lane", String(params.lane));
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
if (params?.pinnedFirst) qs.set("pinned_first", "1");
if (params?.awaiting) qs.set("awaiting", "1");
if (params?.from) qs.set("from", params.from);
if (params?.to) qs.set("to", params.to);
@@ -1066,7 +1069,7 @@ export const api = {
),
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
* workspace, deletes nothing). */
patch: (id: string, body: { name?: string; archived?: boolean }) =>
patch: (id: string, body: { name?: string; archived?: boolean; pinned?: boolean }) =>
request<{ session: Session }>(`/sessions/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(body),
@@ -2440,6 +2443,24 @@ export const api = {
{ method: "DELETE" }
),
},
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
gitIdentity: {
get: () => request<GitIdentity>("/me/git"),
/** Make the SSH key; `replace` swaps an existing one for a new pair. */
generateKey: (replace = false) =>
request<GitIdentity>("/me/git/ssh-key", {
method: "POST",
body: JSON.stringify({ replace }),
}),
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
saveHttps: (body: { host: string; username: string; password: string }) =>
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
removeHttps: (host: string) =>
request<GitIdentity>(`/me/git/https/${encodeURIComponent(host)}`, { method: "DELETE" }),
/** PUT /api/me/git/author — `user.name` / `user.email` for the account's commits. */
saveAuthor: (body: { name: string; email: string }) =>
request<GitIdentity>("/me/git/author", { method: "PUT", body: JSON.stringify(body) }),
},
/** Frozen session copies (`/api/snapshots`, `server/routes/snapshots.js`). */
snapshots: {
list: () => request<{ snapshots: SessionSnapshotInfo[] }>("/snapshots"),
@@ -3046,6 +3067,25 @@ export const api = {
method: "POST",
body: JSON.stringify(body),
}),
/**
* POST /api/lanes/clone — `git clone <url>` into `<parent>/<name>` as the
* folder's account, with that account's own SSH key / HTTPS login, then a
* workspace on it. 409 `ECREDENTIALS` (the error's `auth` names the host)
* asks for a login, sent again as `credentials`; 409 `ESSHAUTH` means the
* SSH key was refused.
*/
clone: (body: {
url: string;
parent: string;
name?: string;
title?: string;
tool?: LaneTool;
credentials?: { username: string; password: string; remember?: boolean };
}) =>
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
method: "POST",
body: JSON.stringify(body),
}),
/**
* GET /api/lanes/pipelines — every pipeline template the server can render
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
@@ -3221,6 +3261,24 @@ export const api = {
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
gitRawUrl: (id: GitTarget, path: string, rev = "work") =>
`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`,
/** GET /api/lanes/:id/git/download — one working file as an attachment
* (any type; a plain, non-git folder too). For an `<a download>`. */
gitDownloadUrl: (id: GitTarget, path: string) =>
`${BASE}${gitUrl(id, "/download", new URLSearchParams({ path }))}`,
/**
* POST /api/lanes/:id/git/upload — create `file` in the folder `dir`
* ("" = the workspace root). Only creates: a taken name answers 409
* EEXISTS; at most 25 MB (413 ETOOBIG).
*/
gitUpload: (id: GitTarget, dir: string, file: File) =>
request<{ path: string }>(gitUrl(id, "/upload", new URLSearchParams({ dir })), {
method: "POST",
body: file,
headers: {
"Content-Type": "application/octet-stream",
"X-File-Name": encodeURIComponent(file.name),
},
}),
/**
* PUT /api/lanes/:id/git/file — save the editor's text over the working
* file. `baseSha` is the `sha` it was opened at; a file that changed on
@@ -3719,6 +3777,24 @@ export interface GitCredential {
hasToken: true;
}
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
export interface GitIdentity {
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
account: "member" | "host";
/** False for the host's identity seen by a non-admin. */
writable: boolean;
ssh: { publicKey: string; fingerprint: string } | null;
/** The account's commit author (`git config --global`); null fields = unset. */
author: { name: string | null; email: string | null };
https: {
protocol: "http" | "https";
host: string;
username: string;
savedAt?: string;
hasToken: true;
}[];
}
export interface GitCredentialInput {
protocol: string;
host: string;
+71
View File
@@ -0,0 +1,71 @@
/**
* @file fontSize — the two text sizes Settings → Appearance offers (issue #79),
* viewer preferences kept in `localStorage` and synced to the account like the
* theme (`lib/userPrefs.ts` PREF_KEYS):
* - `agenthub.ui.fontSize`: the interface's size, the ROOT font size in px.
* Tailwind's sizes and spacing are rem, and `index.css` re-states every
* `text-[Npx]` class the app uses in rem, so the whole UI follows it.
* `index.html` applies it before first paint (same key, same range).
* Capped at 18px: the diff view pins its rows at `ROW_PX` (16px).
* - `agenthub.terminal.fontSize`: xterm's cell font size in px (default 13).
* A write fires `FONT_SIZE_EVENT`, which open terminals listen for to re-fit.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const UI_FONT_SIZE_KEY = "agenthub.ui.fontSize";
export const TERMINAL_FONT_SIZE_KEY = "agenthub.terminal.fontSize";
/** Fired on `window` after either size changes (this tab or a hydrate). */
export const FONT_SIZE_EVENT = "agenthub-font-size";
/** The interface sizes, root px; 16 is the browser's own default. */
export const UI_FONT_SIZES = [14, 16, 17, 18] as const;
export type UiFontSize = (typeof UI_FONT_SIZES)[number];
export const DEFAULT_UI_FONT_SIZE: UiFontSize = 16;
export const TERMINAL_FONT_SIZES = [11, 12, 13, 14, 15, 16, 18, 20] as const;
export const DEFAULT_TERMINAL_FONT_SIZE = 13;
function read(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
function write(key: string, value: number) {
try {
window.localStorage.setItem(key, String(value));
} catch {
/* storage blocked — this page still applies it */
}
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
}
export function readUiFontSize(): UiFontSize {
const n = Number(read(UI_FONT_SIZE_KEY));
return (UI_FONT_SIZES as readonly number[]).includes(n)
? (n as UiFontSize)
: DEFAULT_UI_FONT_SIZE;
}
/** Paint the stored interface size on `<html>` (the default leaves it unset). */
export function applyUiFontSize(size: UiFontSize = readUiFontSize()) {
const root = document.documentElement;
if (size === DEFAULT_UI_FONT_SIZE) root.style.removeProperty("font-size");
else root.style.fontSize = `${size}px`;
}
export function writeUiFontSize(size: UiFontSize) {
applyUiFontSize(size);
write(UI_FONT_SIZE_KEY, size);
}
export function readTerminalFontSize(): number {
const n = Number(read(TERMINAL_FONT_SIZE_KEY));
return (TERMINAL_FONT_SIZES as readonly number[]).includes(n) ? n : DEFAULT_TERMINAL_FONT_SIZE;
}
export function writeTerminalFontSize(size: number) {
write(TERMINAL_FONT_SIZE_KEY, size);
}
+24 -1
View File
@@ -72,9 +72,26 @@ export const SETTINGS_INDEX: {
section: "appearance",
titleKey: "terminalFont.title",
descKey: "terminalFont.description",
keywords: ["font", "phông", "maple", "jetbrains", "monospace", "rounded", "bo tròn"],
keywords: [
"font",
"phông",
"maple",
"jetbrains",
"monospace",
"rounded",
"bo tròn",
"size",
"cỡ chữ",
],
to: "#terminal-font",
},
{
section: "appearance",
titleKey: "textSize.title",
descKey: "textSize.description",
keywords: ["font size", "text size", "zoom", "larger", "cỡ chữ", "chữ to", "phóng to"],
to: "#text-size",
},
{
section: "data",
titleKey: "pushIngest.title",
@@ -314,6 +331,12 @@ export const SETTINGS_INDEX: {
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
{
section: "git",
titleKey: "gitIdentity.title",
keywords: ["ssh", "key", "clone", "https"],
to: "#git-identity",
},
{
section: "git",
titleKey: "worktrees.showUntracked",
+5 -1
View File
@@ -4,10 +4,13 @@
* 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).
* and those glyphs then fall back to a face of another width). A change fires
* `FONT_SIZE_EVENT` (`lib/fontSize.ts`), so an open terminal takes it too.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { FONT_SIZE_EVENT } from "./fontSize";
export const TERMINAL_FONT_KEY = "agenthub.terminal.font";
export const TERMINAL_FONTS = {
@@ -34,4 +37,5 @@ export function writeTerminalFont(font: TerminalFont) {
} catch {
/* storage blocked — terminals keep the default */
}
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
}
+3
View File
@@ -741,6 +741,9 @@ export interface Session {
/** ISO time the user archived the session out of its workspace's lists;
* null when not archived. Hides only. Maps to `sessions.archived_at`. */
archived_at?: string | null;
/** ISO time the user pinned the session to the top of its workspace's
* sidebar list; null when not pinned. Maps to `sessions.pinned_at`. */
pinned_at?: string | null;
}
/**
+5
View File
@@ -19,6 +19,7 @@ import { useSyncExternalStore } from "react";
import { api } from "./api";
import i18n from "../i18n";
import { applyStoredTheme } from "../hooks/useTheme";
import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize";
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
export const PREF_KEYS = [
@@ -27,6 +28,8 @@ export const PREF_KEYS = [
"agenthub.theme.light",
"i18nextLng",
"agenthub.terminal.font",
"agenthub.terminal.fontSize",
"agenthub.ui.fontSize",
"agenthub.pet",
"agenthub.pet.size",
"agenthub.pet.names",
@@ -107,6 +110,8 @@ function reapply(prefs: Record<string, string>) {
applyStoredTheme();
window.dispatchEvent(new Event("agenthub-theme"));
window.dispatchEvent(new Event("sagi:prefs"));
applyUiFontSize();
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
const lng = prefs.i18nextLng;
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
}