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
@@ -154,7 +154,12 @@ describe("WorkspaceTree", () => {
expect(within(list).getByText("Live one")).toBeInTheDocument();
// Its OWN sessions only — never the rest of its folder.
expect(api.sessions.list).toHaveBeenCalledTimes(1);
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 6 });
expect(api.sessions.list).toHaveBeenCalledWith({
lane: 1,
archived: false,
pinnedFirst: true,
limit: 6,
});
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
"1": true,
});
@@ -175,11 +180,21 @@ describe("WorkspaceTree", () => {
expect(within(list).getAllByTestId(/^workspace-session-s-\d+$/)).toHaveLength(6);
expect(screen.getByTestId("workspace-sessions-more-1")).toHaveTextContent("See all (3 more)");
await user.click(screen.getByTestId("workspace-sessions-more-1"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
expect(api.sessions.list).toHaveBeenLastCalledWith({
lane: 1,
archived: false,
pinnedFirst: true,
limit: 9,
});
await screen.findByText("Session 8");
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
await user.click(screen.getByTestId("workspace-sessions-less-1"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 6 });
expect(api.sessions.list).toHaveBeenLastCalledWith({
lane: 1,
archived: false,
pinnedFirst: true,
limit: 6,
});
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
});
@@ -197,6 +212,38 @@ describe("WorkspaceTree", () => {
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
});
it("pins a session above the live ones, and unpins it from its row or menu", async () => {
vi.mocked(api.sessions.list).mockImplementation(async () => ({
sessions: [
session("s-live", "Live one"),
{ ...session("s-old", "Old one"), pinned_at: "2026-10-01T00:00:00Z" } as Session,
],
total: 2,
limit: 20,
offset: 0,
}));
const user = userEvent.setup();
renderTree();
await user.click(await screen.findByTestId("workspace-toggle-1"));
const list = await screen.findByTestId("workspace-sessions-1");
await screen.findByTestId("workspace-session-pinned-s-old");
expect(
within(list)
.getAllByTestId(/^workspace-session-s-/)
.map((el) => el.dataset.testid)
).toEqual(["workspace-session-s-old", "workspace-session-s-live"]);
await user.click(screen.getByTestId("workspace-session-pin-s-old"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { pinned: false });
await user.click(screen.getByTestId("workspace-session-pin-s-live"));
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { pinned: true });
await user.pointer({
keys: "[MouseRight]",
target: screen.getByTestId("workspace-session-s-old"),
});
await user.click(within(screen.getByTestId("session-menu-s-old")).getByText("Unpin"));
expect(api.sessions.patch).toHaveBeenLastCalledWith("s-old", { pinned: false });
});
it("right-click on a session opens its menu: split, rename, archive, close its tab", async () => {
const user = userEvent.setup();
renderTree();
+185 -7
View File
@@ -1,12 +1,15 @@
/**
* @file AddLaneModal.tsx
* @description The "+ Add lane" flow, in one of two modes chosen with a
* @description The "+ Add lane" flow, in one of three modes chosen with a
* segmented toggle — "Repo" by default; a branch prefill (the git bar's "new
* workspace from this branch") opens on "Worktree": "Repo" adopts an existing directory as-is via
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
* main repo you want stage detection on); "Worktree" provisions a
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
* manually-typed branch name. The user also picks which CLI drives the lane
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
* Settings → Git). The user also picks which CLI drives the lane
* (Claude Code or Codex) — this choice is immutable after creation. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
@@ -48,6 +51,17 @@ async function waitForProvisioned(
}
}
/** The folder `git clone <url>` would make: the last path segment, no `.git`. */
export function cloneDirName(url: string): string {
return (
url
.trim()
.replace(/[/:]+$/, "")
.split(/[/:]/)
.pop() || ""
).replace(/\.git$/i, "");
}
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
function Seg({
active,
@@ -97,7 +111,18 @@ export function AddLaneModal({
cwdSuggestions: CwdSuggestion[];
}) {
const { t } = useTranslation(["lanes"]);
const [mode, setMode] = useState<"repo" | "worktree">("repo");
const [mode, setMode] = useState<"repo" | "worktree" | "clone">("repo");
// Clone mode: the URL, the folder name (blank = the URL's), and — once the
// server asked for one — a login for the URL's https host.
const [cloneUrl, setCloneUrl] = useState("");
const [cloneName, setCloneName] = useState("");
const [login, setLogin] = useState<{
host: string;
username: string;
token: string;
remember: boolean;
} | null>(null);
const [sshRefused, setSshRefused] = useState(false);
const [sourceRepo, setSourceRepo] = useState("");
const [title, setTitle] = useState("");
const [branch, setBranch] = useState("");
@@ -141,6 +166,10 @@ export function AddLaneModal({
const reset = () => {
setMode("repo");
setCloneUrl("");
setCloneName("");
setLogin(null);
setSshRefused(false);
setSourceRepo("");
setTitle("");
setBranch("");
@@ -213,6 +242,52 @@ export function AddLaneModal({
setBusy(true);
setError(null);
if (mode === "clone") {
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
try {
const result = await api.lanes.clone({
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
onAdded(result.lane);
reset();
onClose();
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || url;
if (e.code === "ECREDENTIALS") {
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
setLogin((l) => ({
host,
username: l?.username ?? "",
token: "",
remember: l?.remember ?? true,
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
setError(t("clone.sshRefused", { host }));
} else {
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
}
return;
}
if (mode === "repo") {
try {
const result = await api.lanes.ensure({
@@ -337,6 +412,8 @@ export function AddLaneModal({
const disabled =
!done &&
(!sourceRepo.trim() ||
(mode === "clone" &&
(!cloneUrl.trim() || (!!login && (!login.username.trim() || !login.token)))) ||
(mode === "worktree" &&
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
@@ -393,10 +470,38 @@ export function AddLaneModal({
title={t("mode.worktreeHint")}
onClick={() => setMode("worktree")}
/>
<Seg
active={mode === "clone"}
label={t("mode.clone")}
title={t("mode.cloneHint")}
onClick={() => setMode("clone")}
/>
</div>
{mode === "clone" && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-url">
{t("clone.urlLabel")}
</label>
<input
id="add-lane-url"
value={cloneUrl}
onChange={(e) => {
setCloneUrl(e.target.value);
// A login belongs to the host it was asked for.
setLogin(null);
setSshRefused(false);
}}
placeholder="https://github.com/owner/repo.git"
spellCheck={false}
autoComplete="off"
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("clone.urlHint")}</p>
</div>
)}
<div>
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
{noTools ? (
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{t("addLaneNoTools")}{" "}
@@ -457,7 +562,11 @@ export function AddLaneModal({
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
{mode === "repo"
? t("addLaneRepoLabelAdopt")
: mode === "clone"
? t("clone.parentLabel")
: t("addLaneRepoLabel")}
</label>
<div className="flex gap-1.5" data-tour="add-lane-folder">
{/* A click into the path only focuses it — the picker opens from
@@ -473,10 +582,67 @@ export function AddLaneModal({
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
{mode === "clone"
? t("clone.parentHint", {
path: `${sourceRepo.trim().replace(/[\\/]+$/, "") || "…"}/${
cloneName.trim() || cloneDirName(cloneUrl) || "…"
}`,
})
: mode === "repo"
? t("addLaneRepoHintAdopt")
: t("addLaneRepoHint")}
</p>
</div>
{mode === "clone" && (
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-clone-name">
{t("clone.nameLabel")}
</label>
<input
id="add-lane-clone-name"
value={cloneName}
onChange={(e) => setCloneName(e.target.value)}
placeholder={cloneDirName(cloneUrl)}
spellCheck={false}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
</div>
)}
{mode === "clone" && login && (
<div
data-testid="add-lane-clone-login"
className="grid gap-2 rounded-md border border-border-light bg-surface-0 p-2 sm:grid-cols-2"
>
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
autoComplete="off"
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<input
type="password"
aria-label={t("clone.token")}
placeholder={t("clone.token")}
autoComplete="new-password"
value={login.token}
onChange={(e) => setLogin({ ...login, token: e.target.value })}
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary sm:col-span-2">
<input
type="checkbox"
checked={login.remember}
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
/>
{t("clone.remember")}
</label>
</div>
)}
{/* What this repo already has checked out. Without it the form asked
for a branch in the dark: the one fact that decides whether a
branch can be used at all is which worktree is holding it. */}
@@ -690,6 +856,14 @@ export function AddLaneModal({
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
{sshRefused && (
<>
{" "}
<a href="/settings/git#git-identity" className="underline">
{t("clone.sshSettings")}
</a>
</>
)}
</p>
)}
</div>
@@ -710,7 +884,11 @@ export function AddLaneModal({
data-tour="add-lane-create"
className="btn-primary text-xs disabled:opacity-50"
>
{done ? t("addLaneSetupClose") : t("add")}
{done
? t("addLaneSetupClose")
: busy && mode === "clone"
? t("clone.cloning")
: t("add")}
</button>
</div>
</div>
@@ -87,6 +87,7 @@ import {
ChevronsDownUp,
ChevronsUpDown,
Check,
Download,
Code2,
Columns2,
Eye,
@@ -101,6 +102,7 @@ import {
Sparkles,
Trash2,
UnfoldVertical,
Upload,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
@@ -939,6 +941,13 @@ export function LaneChangesPanel({
const [message, setMessage] = useState("");
// The commit row amends the last commit instead of making a new one.
const [amend, setAmend] = useState(false);
// The last commit is already on its upstream: the server refuses to amend
// it (EPUSHED), so the box is not offered — asking first and failing after
// the confirm only costs the user a click.
const headPushed = !!git?.available && !!git.upstream && git.ahead === 0 && !!git.head;
useEffect(() => {
if (headPushed) setAmend(false);
}, [headPushed]);
// The files the commit takes (JetBrains' checkboxes). A path listed for the
// first time arrives with its default; `seenPaths` is the last full list,
// so a poll never re-ticks a file the user unticked.
@@ -1614,8 +1623,22 @@ export function LaneChangesPanel({
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
{t("changes.showHistory")}
</MenuItem>
<MenuItem
testId="lane-changes-menu-download"
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
>
{t("changes.download")}
</MenuItem>
</>
)}
{treeCtx.menu.item.dir && (
<MenuItem
testId="lane-changes-menu-upload"
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
>
{t("changes.uploadHere")}
</MenuItem>
)}
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
{t("changes.copyPath")}
</MenuItem>
@@ -1861,6 +1884,57 @@ export function LaneChangesPanel({
};
const [rereading, runReread] = useSpin(rereadAll);
// Files tab transfers (issue #78): a download is a plain attachment link; an
// upload only creates, into the folder it was asked for ("" = the root).
const uploadInput = useRef<HTMLInputElement>(null);
const uploadDir = useRef("");
const [uploading, setUploading] = useState(false);
const download = (path: string) => {
const a = document.createElement("a");
a.href = api.lanes.gitDownloadUrl(target, path);
a.download = path.split("/").pop() || path;
a.click();
};
const pickUpload = (dir: string) => {
uploadDir.current = dir;
uploadInput.current?.click();
};
const upload = async (files: File[]) => {
if (!files.length) return;
const lane = laneId;
const dir = uploadDir.current;
setUploading(true);
let saved = 0;
try {
for (const f of files) {
try {
await api.lanes.gitUpload(target, dir, f);
saved++;
} catch (err) {
toast.error(
t("changes.uploadFailed", {
name: f.name,
message: err instanceof Error ? err.message : String(err),
})
);
}
}
} finally {
if (laneNow.current === lane) {
setUploading(false);
if (saved) {
toast.success(t("changes.uploaded", { count: saved, dir: dir || "/" }));
// Re-read the level it landed in, and open it.
void api.lanes
.gitTree(target, dir)
.then(forLane(lane, (r) => setTreeChildren((prev) => ({ ...prev, [dir]: r.entries }))))
.catch(() => undefined);
if (dir) setExpandedDirs((prev) => new Set(prev).add(dir));
}
}
}
};
const toggleCollapsed = (path: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
@@ -2047,6 +2121,21 @@ export function LaneChangesPanel({
aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<IconButton label={t("changes.upload")} disabled={uploading} onClick={() => pickUpload("")}>
<Upload className="h-3.5 w-3.5" />
</IconButton>
<input
ref={uploadInput}
type="file"
multiple
hidden
data-testid="lane-changes-upload-input"
onChange={(e) => {
const files = [...(e.target.files ?? [])];
e.target.value = "";
void upload(files);
}}
/>
{!hits && (
<IconButton
label={
@@ -2566,13 +2655,16 @@ export function LaneChangesPanel({
</div>
<div className="flex flex-wrap items-center gap-1.5">
<label
title={t("changes.amendHint")}
className="flex shrink-0 cursor-pointer items-center gap-1 text-[11px] text-fg-secondary"
title={headPushed ? t("changes.amendPushed") : t("changes.amendHint")}
className={`flex shrink-0 items-center gap-1 text-[11px] text-fg-secondary ${
headPushed ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}`}
>
<input
type="checkbox"
data-testid="lane-changes-amend"
checked={amend}
disabled={headPushed}
onChange={(e) => setAmend(e.target.checked)}
/>
{t("changes.amend")}
@@ -2758,6 +2850,18 @@ export function LaneChangesPanel({
{mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{range !== "commit" && openFile && (
<button
type="button"
data-testid="lane-file-download"
onClick={() => download(openFile)}
title={t("changes.download")}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
<Download className="h-3 w-3" />
{t("changes.download")}
</button>
)}
{/* Only the working file is editable: a commit is history. */}
{/* A working file opens in the editor; the read-only diff
(Whole file off) keeps an Edit button back into it. */}
+18 -3
View File
@@ -242,13 +242,27 @@ export function LaneGitBar({
void run(action, body);
}
};
const push = () =>
confirmRun(
const push = async () => {
let ahead = git.ahead ?? 0;
if (git.upstream && ahead === 0) {
// The facts can be up to 30 s old (an agent may have just committed):
// ask git again before saying there is nothing to push — and never
// ask to confirm pushing zero commits.
const read = await api.lanes.git(gitTarget(laneId, session, source)).catch(() => null);
const fresh = read?.available ? read : null;
ahead = fresh?.ahead ?? 0;
if (fresh?.upstream && ahead === 0) {
toast.info(t("gitBar.nothingToPush", { upstream: fresh.upstream }));
onChanged();
return;
}
}
return confirmRun(
git.upstream
? {
title: t("gitBar.push"),
message: t("gitBar.pushConfirm", {
count: git.ahead ?? 0,
count: ahead,
branch: git.branch,
upstream: git.upstream,
}),
@@ -259,6 +273,7 @@ export function LaneGitBar({
},
"push"
);
};
const publish = () =>
confirmRun(
{
+9 -1
View File
@@ -4,7 +4,7 @@
* the sidebar's **New session** row). Every item is something the row already
* does: open it the way its click does (a live pane attaches, an ended session
* opens its conversation — never a resume), open a live one in a split,
* rename / archive / unarchive when the list offers them, copy its id, open
* rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -29,6 +29,7 @@ export function SessionMenu({
onRename,
onArchive,
onUnarchive,
onPin,
}: MenuPoint & {
laneId: number;
session: Session | null;
@@ -38,6 +39,8 @@ export function SessionMenu({
onRename?: () => void;
onArchive?: () => void;
onUnarchive?: () => void;
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
onPin?: () => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -84,6 +87,11 @@ export function SessionMenu({
</MenuItem>
)}
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>}
{onPin && session && (
<MenuItem onSelect={pick(onPin)}>
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
</MenuItem>
)}
{session && (
<>
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
@@ -1,12 +1,13 @@
/**
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
* sidebar: the ones the workspace OWNS and has not archived
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
* pane above the ended ones) — never the other
* (`GET /api/sessions?lane=<id>&archived=0&pinned_first=1`, newest first —
* pinned ones on top in pin order, then the ones with a live pane, then the
* ended ones; a pin is `PATCH {pinned}`, issue #80) — never the other
* sessions that ran in its folder. A row's dot
* shows its state (`sessionDot`: live pane, needs you, error) and its right
* edge the AI tool the session ran on (`sessions.tool`); double-click
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
* renames it, its hover buttons pin/unpin and archive it (`PATCH {name}` / `{archived}`,
* which deletes nothing); a right-click opens the same actions and more
* (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle
* carrying its session id) opens that tmux pane on the Workspace page
@@ -33,7 +34,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
@@ -278,17 +279,21 @@ export function WorkspaceSessionList({
3000
);
const refreshKey = `${hookKey}:${version}`;
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const own = useSessionPage(
{ lane: lane.id, archived: false, pinnedFirst: true, limit },
refreshKey
);
const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
const q = query.trim().toLowerCase();
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
// Live sessions first, ended ones under them — each group stays newest
// first (the sort is stable over the server's order).
// Pinned sessions first, then live ones, then ended ones — each group in
// the server's order (pin order, else newest first; the sort is stable).
const rank = (s: Session) => (s.pinned_at ? 0 : runFor(s) ? 1 : 2);
const shown = (sessions ?? [])
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
.sort((a, b) => rank(a) - rank(b));
// A live tab whose session has no row yet: shown as a New session at the
// top, like Claude Desktop, until its first hook brings the real row. Not
// during a search — it has no name to match.
@@ -336,7 +341,10 @@ export function WorkspaceSessionList({
if (count !== null) onMatches?.(count);
}, [count, onMatches]);
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => {
const patch = async (
s: Session,
body: { name?: string; archived?: boolean; pinned?: boolean }
) => {
setError(null);
try {
await api.sessions.patch(s.id, body);
@@ -533,6 +541,13 @@ export function WorkspaceSessionList({
/>
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
{s.pinned_at && (
<Pin
data-testid={`workspace-session-pinned-${s.id}`}
aria-label={t("tree.pinned")}
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
{sessionWorktreeName(lane, s) && (
<GitBranch
data-testid={`workspace-session-worktree-${s.id}`}
@@ -540,7 +555,9 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
<span
className={`ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted ${readOnly ? "" : "group-hover:invisible"}`}
>
{timeAgo(s.last_activity ?? s.started_at, true)}
</span>
<AiToolIcon
@@ -548,6 +565,22 @@ export function WorkspaceSessionList({
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
/>
</button>
{!readOnly && (
<button
type="button"
data-testid={`workspace-session-pin-${s.id}`}
onClick={() => void patch(s, { pinned: !s.pinned_at })}
title={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
aria-label={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
className="absolute right-6 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
{s.pinned_at ? (
<PinOff className="h-3.5 w-3.5" />
) : (
<Pin className="h-3.5 w-3.5" />
)}
</button>
)}
{!readOnly && (
<button
type="button"
@@ -608,6 +641,9 @@ export function WorkspaceSessionList({
onClose={sessionCtx.close}
onRename={() => startRename(sessionCtx.menu!.item)}
onArchive={() => void archive(sessionCtx.menu!.item)}
onPin={() =>
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
}
/>
)}
</>
@@ -25,6 +25,7 @@ vi.mock("../../../lib/api", () => ({
pipelines: vi.fn(),
worktree: vi.fn(),
ensure: vi.fn(),
clone: vi.fn(),
browse: vi.fn(),
pickPath: vi.fn(),
// The folder browser's Places list the user's workspaces.
@@ -156,6 +157,7 @@ beforeEach(() => {
vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.clone).mockReset();
vi.mocked(api.lanes.browse).mockReset();
vi.mocked(api.lanes.pickPath).mockReset();
// Provisioning finishes instantly by default - tests that care about the
@@ -693,7 +695,7 @@ describe("AddLaneModal — tool selection", () => {
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
expect(picker).toHaveAttribute("aria-expanded", "false");
await user.click(picker);
const list = screen.getByRole("group", { name: /Which CLI drives this workspace/ });
const list = screen.getByRole("group", { name: "Default AI tool" });
expect(
within(list)
.getAllByRole("button")
@@ -779,3 +781,76 @@ describe("AddLaneModal — working straight on main (no worktree)", () => {
);
});
});
describe("AddLaneModal — Clone", () => {
async function fillClone(user: ReturnType<typeof userEvent.setup>, url: string) {
renderModal();
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
const urlField = screen.getByLabelText("Repository URL");
await focusField(user, urlField);
await user.type(urlField, url);
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
}
it("clones into <folder>/<repo name> and reports the new workspace", async () => {
const onAdded = vi.fn();
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: laneFixture({ id: 5 }),
created: true,
path: "/home/an/code/repo",
});
const user = userEvent.setup();
renderModal({ onAdded });
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
const urlField = screen.getByLabelText("Repository URL");
await focusField(user, urlField);
await user.type(urlField, "git@github.com:owner/repo.git");
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
expect(screen.getByText(/cloned to \/home\/an\/code\/repo/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith(
expect.objectContaining({ url: "git@github.com:owner/repo.git", parent: "/home/an/code" })
)
);
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
Object.assign(new Error("needs a login"), {
code: "ECREDENTIALS",
auth: { protocol: "https", host: "git.example.com" },
})
)
.mockResolvedValueOnce({ lane: laneFixture({ id: 6 }), created: true, path: "/x" });
const user = userEvent.setup();
await fillClone(user, "https://git.example.com/o/r.git");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
const login = await screen.findByTestId("add-lane-clone-login");
expect(screen.getByRole("alert")).toHaveTextContent("git.example.com needs a login");
await user.type(within(login).getByLabelText("Username"), "an");
await user.type(within(login).getByLabelText("Password or access token"), "tok");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(vi.mocked(api.lanes.clone).mock.calls[1]?.[0].credentials).toEqual({
username: "an",
password: "tok",
remember: true,
})
);
});
it("points to Settings → Git when the SSH key is refused", async () => {
vi.mocked(api.lanes.clone).mockRejectedValue(
Object.assign(new Error("refused"), { code: "ESSHAUTH" })
);
const user = userEvent.setup();
await fillClone(user, "git@github.com:o/r.git");
await user.click(screen.getByRole("button", { name: "Add workspace" }));
const link = await screen.findByRole("link", { name: "Open Settings → Git" });
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
});
});
@@ -15,7 +15,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react";
import { render, screen, waitFor, act, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
@@ -75,6 +75,9 @@ vi.mock("../../../lib/api", () => ({
gitTree: vi.fn(),
gitTreeAll: vi.fn(),
gitBlame: vi.fn(),
gitDownloadUrl: (id: number, path: string) =>
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
gitUpload: vi.fn(),
},
},
}));
@@ -545,6 +548,44 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
});
it("uploads files into a folder from the tree menu, and downloads a file (issue #78)", async () => {
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
available: true,
path,
entries:
path === "out"
? [{ name: "report.md", path: "out/report.md", dir: false }]
: [{ name: "out", path: "out", dir: true }],
truncated: false,
}));
vi.mocked(api.lanes.gitUpload).mockResolvedValue({ path: "out/data.csv" });
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
const input = within(sidebar).getByTestId("lane-changes-upload-input") as HTMLInputElement;
const picked = vi.spyOn(input, "click").mockImplementation(() => {});
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
await user.click(screen.getByTestId("lane-changes-menu-upload"));
expect(picked).toHaveBeenCalled();
const file = new File(["a,b\n"], "data.csv", { type: "text/csv" });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(api.lanes.gitUpload).toHaveBeenCalledWith(7, "out", file));
// The folder it landed in is re-read and opened.
expect(await within(sidebar).findByText("report.md")).toBeInTheDocument();
const clicked: string[] = [];
const anchor = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement
) {
clicked.push(this.getAttribute("href") ?? "");
});
await user.pointer({ keys: "[MouseRight]", target: within(sidebar).getByText("report.md") });
await user.click(screen.getByTestId("lane-changes-menu-download"));
expect(clicked).toEqual(["/api/lanes/7/git/download?path=out%2Freport.md"]);
anchor.mockRestore();
picked.mockRestore();
});
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
@@ -21,7 +21,7 @@ import type { LaneGitFacts } from "../../../lib/types";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn(), git: vi.fn() } },
}));
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
@@ -164,6 +164,29 @@ describe("LaneGitBar", () => {
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("says there is nothing to push instead of asking to push zero commits", async () => {
const user = userEvent.setup();
vi.mocked(confirmDialog).mockClear();
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 0 }));
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Push$/ }));
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("re-reads stale facts: a commit made since still gets the push confirm", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 1 }));
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Push$/ }));
await waitFor(() =>
expect(confirmDialog).toHaveBeenLastCalledWith(
expect.objectContaining({ message: expect.stringContaining("Push 1 commit") })
)
);
});
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
const user = userEvent.setup();
render(
+14 -1
View File
@@ -64,6 +64,7 @@ import { writeClipboard } from "../../lib/clipboard";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
type DropState = "hint" | "paste" | "uploading" | "failed";
@@ -284,7 +285,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
// left margin of the rows below. That is the torn left edge of issue #33,
// and why it only showed up once content scrolled.
const term = new Terminal({
fontSize: 13,
fontSize: readTerminalFontSize(),
cursorBlink: true,
fontFamily: TERMINAL_FONTS[readTerminalFont()],
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
@@ -576,6 +577,17 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
refit();
};
document.fonts?.addEventListener("loadingdone", onFontLoaded);
// Settings → Appearance changed the terminal's font or size: an open
// pane takes it now, re-measured and re-fitted like a font landing.
const onFontPref = () => {
const size = readTerminalFontSize();
const family = TERMINAL_FONTS[readTerminalFont()];
if (term.options.fontSize === size && term.options.fontFamily?.trimEnd() === family) return;
term.options.fontSize = size;
term.options.fontFamily = family;
refit();
};
window.addEventListener(FONT_SIZE_EVENT, onFontPref);
return () => {
if (retryTimer) clearTimeout(retryTimer);
@@ -583,6 +595,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
searchRef.current = null;
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
term.textarea?.removeEventListener("focus", reassert);
detachIme();
resizeDisposable.dispose();
@@ -1,10 +1,13 @@
/**
* @file AppearanceSettings.tsx
* @description Settings → Appearance (`/settings/appearance`): the per-user
* Chat UI default (`ChatUiSettings`, `#chat-ui`), whether the session view's
* tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
* `#workspace-tabs`), the interface language, the
* terminal font (`lib/terminalFont.ts`, with a preview line), the
* @description Settings → Appearance (`/settings/appearance`), grouped so
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
* `#workspace-tabs`); then the
* Dark / Light / System mode switch and one named-theme picker per mode, and the
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
@@ -34,6 +37,15 @@ import {
type TerminalFont,
} from "../../lib/terminalFont";
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
import {
TERMINAL_FONT_SIZES,
UI_FONT_SIZES,
readTerminalFontSize,
readUiFontSize,
writeTerminalFontSize,
writeUiFontSize,
type UiFontSize,
} from "../../lib/fontSize";
export const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
@@ -82,6 +94,8 @@ export function AppearanceSettings() {
const { t, i18n } = useTranslation("settings");
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
const [termFont, setTermFont] = useState(readTerminalFont);
const [termSize, setTermSize] = useState(readTerminalFontSize);
const [uiSize, setUiSize] = useState(readUiFontSize);
const [allTabs, setAllTabs] = useState(readAllTabs);
const pickTermFont = (font: TerminalFont) => {
writeTerminalFont(font);
@@ -103,31 +117,11 @@ export function AppearanceSettings() {
return (
<div className="space-y-6">
<ChatUiSettings />
<SettingsSection
id="workspace-tabs"
title={t("workspaceTabs.title")}
description={t("workspaceTabs.description")}
>
<SettingsSwitchRow
label={t("workspaceTabs.label")}
description={t("workspaceTabs.labelDesc")}
checked={allTabs}
onChange={(on) => {
writeAllTabs(on);
setAllTabs(on);
}}
/>
</SettingsSection>
<SettingsSection
id="language"
title={t("language.title")}
description={t("language.description")}
>
<SettingsSection id="fonts" title={t("fonts.title")} description={t("fonts.description")}>
<SettingsRow
id="language"
label={t("language.label")}
description={t("language.description")}
control={
<SettingsSegmented
ariaLabel={t("language.label")}
@@ -140,16 +134,34 @@ export function AppearanceSettings() {
/>
}
/>
</SettingsSection>
<SettingsSection
id="terminal-font"
title={t("terminalFont.title")}
description={t("terminalFont.description")}
>
<SettingsRow
label={t("terminalFont.label")}
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
id="text-size"
label={t("textSize.label")}
description={t("textSize.description")}
control={
<SettingsSegmented
ariaLabel={t("textSize.label")}
value={String(uiSize)}
onChange={(v) => {
const size = Number(v) as UiFontSize;
writeUiFontSize(size);
setUiSize(size);
}}
options={UI_FONT_SIZES.map((size) => ({
value: String(size),
label: t(`textSize.names.${size}`),
}))}
/>
}
/>
<SettingsRow
id="terminal-font"
label={t("terminalFont.title")}
description={
termFont === "system"
? `${t("terminalFont.description")} ${t("terminalFont.systemNote")}`
: t("terminalFont.description")
}
control={
// A dropdown: five font names overflow a segmented row.
<Select<TerminalFont>
@@ -163,17 +175,57 @@ export function AppearanceSettings() {
/>
}
/>
<SettingsRow
label={t("terminalFont.sizeLabel")}
control={
<Select
label={t("terminalFont.sizeLabel")}
value={String(termSize)}
onChange={(v) => {
writeTerminalFontSize(Number(v));
setTermSize(Number(v));
}}
options={TERMINAL_FONT_SIZES.map((size) => ({
value: String(size),
label: `${size}px`,
}))}
/>
}
/>
<p
data-testid="terminal-font-preview"
aria-label={t("terminalFont.preview")}
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary"
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-fg-primary"
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
// In px like xterm's cells: the interface's text size does not scale it.
style={{
fontFamily: TERMINAL_FONTS[termFont],
fontSize: termSize,
fontVariantLigatures: "none",
}}
>
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
</p>
</SettingsSection>
<SettingsSection
id="sessions-view"
title={t("sessionsView.title")}
description={t("sessionsView.description")}
>
<ChatUiSettings />
<SettingsSwitchRow
id="workspace-tabs"
label={t("workspaceTabs.label")}
description={t("workspaceTabs.labelDesc")}
checked={allTabs}
onChange={(on) => {
writeAllTabs(on);
setAllTabs(on);
}}
/>
</SettingsSection>
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
<SettingsRow
@@ -1,7 +1,8 @@
/**
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
* @file ChatUiSettings — Settings → Appearance → Sessions, the Chat UI row
* (`#chat-ui`): whether a Claude or Codex session opens as a chat like Claude
* Desktop (`PaneChat`) instead of its terminal. A row, not a card: it sits in
* the Sessions section beside the workspace-tabs switch. Saved per user on the server (`lib/chatUi.ts`), so each
* account on a shared dashboard keeps its own choice on any browser.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -9,25 +10,22 @@
import { useTranslation } from "react-i18next";
import { saveChatUi, useChatUi } from "../../lib/chatUi";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
import { SettingsSwitchRow } from "./SettingsRow";
export function ChatUiSettings() {
const { t } = useTranslation("settings");
const chatUi = useChatUi();
return (
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
<SettingsSwitchRow
label={t("chatUi.label")}
description={t("chatUi.labelDesc")}
checked={chatUi}
onChange={(on) =>
saveChatUi(on).catch((err: unknown) =>
toast.error(
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
)
)
}
/>
</SettingsSection>
<SettingsSwitchRow
id="chat-ui"
label={t("chatUi.label")}
description={t("chatUi.labelDesc")}
checked={chatUi}
onChange={(on) =>
saveChatUi(on).catch((err: unknown) =>
toast.error(t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" }))
)
}
/>
);
}
@@ -0,0 +1,341 @@
/**
* @file GitIdentitySettings.tsx
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
* commit author (name + email in the account's global git config — prefilled
* from Settings → Account while unset, since a member's commits fail without
* one), the SSH
* key a clone (Add workspace → Clone) and the pushes from that clone use —
* make one, copy its public half to add to GitHub/GitLab, replace or delete
* it — and, for a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and
* every token are write-only: the server never returns them. A host identity
* seen by a non-admin is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { useAuth } from "../AuthGate";
const INPUT =
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
const BTN =
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [form, setForm] = useState<{ host: string; username: string; token: string } | null>(null);
const auth = useAuth().state;
const me = auth.mode === "server" ? auth.user : null;
// null = not edited: the field shows the saved value, else the account's.
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
const load = useCallback(async () => {
try {
setId(await api.gitIdentity.get());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const run = async (fn: () => Promise<GitIdentity>, done?: string) => {
setBusy(true);
setError(null);
try {
setId(await fn());
if (done) toast.success(done);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
return false;
} finally {
setBusy(false);
}
};
const generate = async () => {
if (
id?.ssh &&
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(() => api.gitIdentity.generateKey(Boolean(id?.ssh)), t("gitIdentity.keyMade"));
};
const removeKey = async () => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeKey());
};
const saveLogin = async () => {
if (!form) return;
// A bare host is the usual entry; a pasted URL is reduced to its host.
let host = form.host.trim();
try {
if (/^https?:\/\//i.test(host)) host = new URL(host).host;
} catch {
/* the server answers EBADHOST */
}
const ok = await run(
() =>
api.gitIdentity.saveHttps({ host, username: form.username.trim(), password: form.token }),
t("gitCreds.saved", { host })
);
if (ok) setForm(null);
};
const removeLogin = async (host: string) => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeLoginConfirm", { host }),
confirmLabel: t("gitIdentity.remove"),
}))
)
return;
await run(() => api.gitIdentity.removeHttps(host));
};
if (!id) {
return error ? (
<p className="text-xs text-status-danger">{error}</p>
) : (
<p className="text-xs text-fg-muted">{t("gitCreds.loading")}</p>
);
}
const member = id.account === "member";
const authorShown = author ?? {
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
email: id.author.email ?? me?.email ?? "",
};
const authorUnset = !id.author.name || !id.author.email;
const authorDirty =
authorUnset ||
authorShown.name.trim() !== (id.author.name ?? "") ||
authorShown.email.trim() !== (id.author.email ?? "");
const saveAuthor = async () => {
const ok = await run(
() =>
api.gitIdentity.saveAuthor({
name: authorShown.name.trim(),
email: authorShown.email.trim(),
}),
t("gitIdentity.authorSaved")
);
if (ok) setAuthor(null);
};
return (
<div className="space-y-4">
<p className="max-w-2xl text-xs text-fg-muted">
{member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
</p>
{error && (
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
{error}
</p>
)}
<form
className="max-w-2xl space-y-2 rounded-lg border border-border p-3"
onSubmit={(e) => {
e.preventDefault();
void saveAuthor();
}}
>
<h3 className="text-xs font-semibold text-fg-primary">{t("gitIdentity.authorTitle")}</h3>
{authorUnset && (
<p className="text-[11px] text-status-warning">{t("gitIdentity.authorUnset")}</p>
)}
<div className="grid gap-2 sm:grid-cols-2">
<input
aria-label={t("gitIdentity.authorName")}
placeholder={t("gitIdentity.authorName")}
value={authorShown.name}
disabled={!id.writable}
onChange={(e) => setAuthor({ ...authorShown, name: e.target.value })}
className={INPUT}
/>
<input
type="email"
aria-label={t("gitIdentity.authorEmail")}
placeholder="name@example.com"
value={authorShown.email}
disabled={!id.writable}
onChange={(e) => setAuthor({ ...authorShown, email: e.target.value })}
className={INPUT}
/>
</div>
{id.writable && (
<button
type="submit"
disabled={busy || !authorDirty || !authorShown.name.trim() || !authorShown.email.trim()}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitCreds.save")}
</button>
)}
</form>
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.sshTitle")}
</h3>
{id.writable && (
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{id.ssh ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
</div>
{id.ssh ? (
<>
<textarea
readOnly
aria-label={t("gitIdentity.publicKey")}
value={id.ssh.publicKey}
rows={3}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
/>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{id.ssh.fingerprint}
</span>
<button
type="button"
className={BTN}
onClick={() => void copyText(id.ssh?.publicKey ?? "")}
>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void removeKey()}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
</>
) : (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
)}
</div>
{member && (
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.httpsTitle")}
</h3>
<button
type="button"
className={BTN}
onClick={() => setForm({ host: "", username: "", token: "" })}
>
<Plus className="h-3.5 w-3.5" />
{t("gitCreds.add")}
</button>
</div>
{form && (
<form
className="grid gap-2 sm:grid-cols-3"
onSubmit={(e) => {
e.preventDefault();
void saveLogin();
}}
>
<input
aria-label={t("gitCreds.fieldHost")}
placeholder="github.com"
value={form.host}
onChange={(e) => setForm({ ...form, host: e.target.value })}
className={`${INPUT} font-mono`}
/>
<input
aria-label={t("gitCreds.fieldUser")}
placeholder={t("gitCreds.fieldUser")}
autoComplete="off"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className={INPUT}
/>
<input
type="password"
aria-label={t("gitCreds.fieldToken")}
placeholder={t("gitCreds.fieldToken")}
autoComplete="new-password"
value={form.token}
onChange={(e) => setForm({ ...form, token: e.target.value })}
className={INPUT}
/>
<div className="flex gap-2 sm:col-span-3">
<button
type="submit"
disabled={busy || !form.host.trim() || !form.username.trim() || !form.token}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitCreds.save")}
</button>
<button type="button" onClick={() => setForm(null)} className={BTN}>
{t("gitCreds.cancel")}
</button>
</div>
</form>
)}
{id.https.length === 0 ? (
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noLogins")}</p>
) : (
<ul className="divide-y divide-border text-xs">
{id.https.map((c) => (
<li key={c.host} className="flex items-center gap-2 py-1.5">
<span className="font-mono text-[11px] text-fg-primary">{c.host}</span>
<span className="flex-1 text-fg-secondary">{c.username}</span>
<button
type="button"
aria-label={`${t("gitIdentity.remove")} ${c.host}`}
title={t("gitIdentity.remove")}
disabled={busy}
onClick={() => void removeLogin(c.host)}
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -37,15 +37,19 @@ export function SettingsRow({
description,
control,
alignTop = false,
id,
}: {
label: ReactNode;
description?: ReactNode;
control: ReactNode;
alignTop?: boolean;
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
id?: string;
}) {
return (
<div
className={`flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
id={id}
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
description ? "py-3" : "py-2"
} ${alignTop ? "items-start" : "items-center"}`}
>
@@ -64,15 +68,18 @@ export function SettingsSwitchRow({
checked,
onChange,
disabled = false,
id,
}: {
label: string;
description?: ReactNode;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
id?: string;
}) {
return (
<SettingsRow
id={id}
label={label}
description={description}
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
@@ -1,6 +1,6 @@
/**
* @file AppearanceSettings.test.tsx
* @description Tests for the Settings → Appearance section: the language switch, the terminal font picker, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
* @description Tests for the Settings → Appearance section: the language switch, the text size, the terminal font and size pickers, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -20,6 +20,7 @@ describe("AppearanceSettings", () => {
root.classList.remove("dark");
root.removeAttribute("data-theme");
root.removeAttribute("data-mode");
root.style.removeProperty("font-size");
});
it("switches the interface language and holds the pet block", async () => {
@@ -102,4 +103,33 @@ describe("AppearanceSettings", () => {
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
});
it("sets the interface text size on <html>, the default leaving it unset", async () => {
const user = userEvent.setup();
render(<AppearanceSettings />);
const group = screen.getByRole("radiogroup", { name: "Interface text" });
expect(within(group).getByRole("radio", { name: "Default" })).toHaveAttribute(
"aria-checked",
"true"
);
await user.click(within(group).getByRole("radio", { name: "Larger" }));
expect(root.style.fontSize).toBe("18px");
expect(localStorage.getItem("agenthub.ui.fontSize")).toBe("18");
await user.click(within(group).getByRole("radio", { name: "Default" }));
expect(root.style.fontSize).toBe("");
});
it("picks the terminal font size and tells open terminals", async () => {
const user = userEvent.setup();
const heard = vi.fn();
window.addEventListener("agenthub-font-size", heard);
render(<AppearanceSettings />);
expect(screen.getByRole("button", { name: /^Size/ })).toHaveTextContent("13px");
await user.click(screen.getByRole("button", { name: /^Size/ }));
await user.click(screen.getByRole("option", { name: "16px" }));
expect(localStorage.getItem("agenthub.terminal.fontSize")).toBe("16");
expect(screen.getByTestId("terminal-font-preview").style.fontSize).toBe("16px");
expect(heard).toHaveBeenCalled();
window.removeEventListener("agenthub-font-size", heard);
});
});
@@ -0,0 +1,113 @@
/**
* @file Tests for Settings → Git → Your git identity: a user with no key
* makes one, a key shows only its public half and copies it, replacing asks
* first, and a member (S3) adds an HTTPS login of their own while the host's
* view leaves HTTPS to the shared Git credentials table.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GitIdentitySettings } from "../GitIdentitySettings";
import { api, type GitIdentity } from "../../../lib/api";
import { confirmDialog } from "../../../lib/confirm";
import { copyText } from "../../../lib/clipboard";
vi.mock("../../../lib/api", () => ({
api: {
gitIdentity: {
get: vi.fn(),
generateKey: vi.fn(),
removeKey: vi.fn(),
saveHttps: vi.fn(),
saveAuthor: vi.fn(),
removeHttps: vi.fn(),
},
},
}));
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
account: "member",
writable: true,
ssh: null,
https: [],
author: { name: "An", email: "an@x.vn" },
...over,
});
beforeEach(() => {
vi.clearAllMocks();
});
describe("GitIdentitySettings", () => {
it("makes a key when there is none, without asking", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false);
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey);
});
it("copies the public key and asks before replacing it", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: KEY }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Copy public key" }));
expect(copyText).toHaveBeenCalledWith(KEY.publicKey);
await userEvent.click(screen.getByRole("button", { name: "Replace key" }));
expect(confirmDialog).toHaveBeenCalled();
await waitFor(() => expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(true));
});
it("lets a member save an https login of their own", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
vi.mocked(api.gitIdentity.saveHttps).mockResolvedValue(
view({ https: [{ protocol: "https", host: "github.com", username: "an", hasToken: true }] })
);
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Add login" }));
await userEvent.type(screen.getByLabelText("Server URL"), "https://github.com/o/r");
await userEvent.type(screen.getByLabelText("Username"), "an");
await userEvent.type(screen.getByLabelText("Password or access token"), "tok");
// The second Save: the first belongs to the commit-author form.
await userEvent.click(screen.getAllByRole("button", { name: "Save" })[1]!);
expect(api.gitIdentity.saveHttps).toHaveBeenCalledWith({
host: "github.com",
username: "an",
password: "tok",
});
expect(await screen.findByText("github.com")).toBeInTheDocument();
});
it("warns while the commit author is unset and saves it", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
render(<GitIdentitySettings />);
expect(
await screen.findByText(/commits from the dashboard and your agents fail/)
).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Name"), "An Nguyen");
await userEvent.type(screen.getByLabelText("Email"), "an@x.vn");
await userEvent.click(screen.getAllByRole("button", { name: "Save" })[0]!);
expect(api.gitIdentity.saveAuthor).toHaveBeenCalledWith({
name: "An Nguyen",
email: "an@x.vn",
});
});
it("shows the host's key read-only and no https list", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ account: "host", writable: false, ssh: KEY })
);
render(<GitIdentitySettings />);
expect(await screen.findByLabelText("Public key")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
});
});
+29 -1
View File
@@ -34,7 +34,7 @@
"addLaneToolDefaultBadge": "Default",
"addLaneNoTools": "No AI tool is installed and enabled.",
"addLaneNoToolsLink": "Open Settings → AI Tools",
"addLaneToolHint": "Which CLI drives this workspace. Cannot be changed later.",
"addLaneToolHint": "Default AI tool",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} more",
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
@@ -84,6 +84,12 @@
"changes.expand": "Show the changes panel",
"changes.expandAll": "Expand every file",
"changes.expandAllFolders": "Expand all folders",
"changes.download": "Download",
"changes.upload": "Upload files to the workspace root",
"changes.uploadHere": "Upload files here…",
"changes.uploaded_one": "Uploaded {{count}} file to {{dir}}",
"changes.uploaded_other": "Uploaded {{count}} files to {{dir}}",
"changes.uploadFailed": "Could not upload {{name}}: {{message}}",
"changes.expandContext": "Show more surrounding lines",
"changes.expandFile": "Expand {{path}}",
"changes.fileGone": "That file no longer has changes in this range.",
@@ -186,6 +192,7 @@
"gitBar.pull": "Pull",
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
"gitBar.push": "Push",
"gitBar.nothingToPush": "Nothing to push — {{upstream}} already has every commit.",
"gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Branch",
"gitBar.switchApply": "Switch branch",
@@ -200,6 +207,21 @@
"mode.repoHint": "Adopt this directory as a workspace, as-is — no worktree, no new branch.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
"mode.clone": "Clone",
"mode.cloneHint": "Clone a repository over HTTPS or SSH into one of your folders, as a new workspace.",
"clone.urlLabel": "Repository URL",
"clone.urlHint": "https://host/owner/repo.git, git@host:owner/repo.git or ssh://… — SSH uses your own key (Settings → Git).",
"clone.parentLabel": "Clone into folder",
"clone.parentHint": "The repository is cloned to {{path}}",
"clone.nameLabel": "Folder name",
"clone.cloning": "Cloning…",
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
"clone.loginRejected": "{{host}} rejected that username or token.",
"clone.username": "Username",
"clone.token": "Password or access token",
"clone.remember": "Remember this login for my account",
"clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.",
"clone.sshSettings": "Open Settings → Git",
"moreActions": "More actions",
"pipelinePicker.label": "Pipeline template",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
@@ -268,6 +290,7 @@
"changes.mergeAbortConfirm": "Click again to abort the {{op}}",
"changes.discardShortConfirm": "Discard?",
"changes.amend": "Amend",
"changes.amendPushed": "The last commit is already pushed; amending it would rewrite shared history. Revert it from History instead.",
"changes.amendHint": "Fold the ticked files into the last commit (only before it is pushed). Nothing ticked rewords it; leave the message empty to keep the old one.",
"changes.amendPlaceholder": "New message (empty = keep the old one)",
"changes.historyOf": "History of branch",
@@ -344,6 +367,9 @@
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
"tree.renameSession": "Rename session",
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
"tree.pinSession": "Pin to the top of this workspace",
"tree.unpinSession": "Unpin",
"tree.pinned": "Pinned",
"tree.worktreeSession": "In its own worktree: .claude/worktrees/{{name}}",
"tree.archiveWorktreeTitle": "Archive and remove its worktree?",
"tree.archiveWorktreeBody": "Like Claude Desktop, archiving removes the session's worktree .claude/worktrees/{{name}} and its branch. Git refuses if it holds uncommitted changes, and a branch with commits merged nowhere is kept — nothing unsaved is lost.",
@@ -423,6 +449,8 @@
"sessionMenu.details": "Session details",
"sessionMenu.copyId": "Copy session ID",
"sessionMenu.archive": "Archive",
"sessionMenu.pin": "Pin",
"sessionMenu.unpin": "Unpin",
"panes.show": "Show {{name}} here",
"panes.splitNew.right": "Split right with a new terminal",
"panes.splitNew.bottom": "Split down with a new terminal",
+46 -2
View File
@@ -893,6 +893,30 @@
"pet": "Pet",
"petDescription": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
},
"gitIdentity": {
"authorTitle": "Commit author",
"authorName": "Name",
"authorEmail": "Email",
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
"authorSaved": "Commit author saved.",
"title": "Your git identity",
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
"descriptionHost": "The SSH key of the account this AgentHub runs as. Add workspace → Clone uses it, and the clone keeps it for its later fetch, pull and push. HTTPS logins for this account are the Git credentials above.",
"sshTitle": "SSH key",
"generate": "Generate SSH key",
"replace": "Replace key",
"replaceConfirm": "Replace the SSH key? Clones that use the old key stop working until the new public key is added to their host.",
"keyMade": "SSH key ready — add its public key to your git host.",
"remove": "Delete",
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
"publicKey": "Public key",
"copy": "Copy public key",
"addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys). The private key never leaves the server.",
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
"httpsTitle": "HTTPS logins",
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again."
},
"gitCreds": {
"title": "Git credentials",
"description": "One login per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
@@ -1061,6 +1085,14 @@
"agenthub": "AgentHub-managed worktrees",
"agenthubDesc": "Worktrees AgentHub created under its workspaces root."
},
"fonts": {
"title": "Text & fonts",
"description": "Language, text size and the terminal's font. Saved to your account."
},
"sessionsView": {
"title": "Sessions",
"description": "How a new session opens and which sessions the session view's tab bar holds. Saved to your account."
},
"language": {
"title": "Language",
"description": "The language of the whole dashboard, saved to your account.",
@@ -1076,9 +1108,20 @@
"label": "Tabs from every workspace",
"labelDesc": "Keep every workspace's live sessions as tabs, and mix them in one split. Focusing a tab selects its workspace. Off: only the selected workspace's sessions."
},
"textSize": {
"title": "Text size",
"description": "How large the dashboard's text is — menus, lists, chat and settings. Saved to your account. Ctrl + / Ctrl − in the browser still zooms everything on top of this.",
"label": "Interface text",
"names": {
"14": "Small",
"16": "Default",
"17": "Large",
"18": "Larger"
}
},
"terminalFont": {
"title": "Terminal font",
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved to your account.",
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
"label": "Font",
"names": {
"jetbrains": "JetBrains Mono",
@@ -1086,7 +1129,8 @@
"system": "System font"
},
"systemNote": "Your browser's fixed-width font. It may lack some Vietnamese letters (ấ, ế, ố…), which then draw out of line.",
"preview": "Preview"
"preview": "Preview",
"sizeLabel": "Size"
},
"rowMenu": {
"copyHost": "Copy host",
+29 -1
View File
@@ -34,7 +34,7 @@
"addLaneToolDefaultBadge": "Mặc định",
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
"addLaneToolHint": "CLI nào điều khiển workspace này. Không thể thay đổi sau này.",
"addLaneToolHint": "AI tool mặc định",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} nữa",
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
@@ -84,6 +84,12 @@
"changes.expand": "Mở bảng thay đổi",
"changes.expandAll": "Mở tất cả file",
"changes.expandAllFolders": "Mở tất cả thư mục",
"changes.download": "Tải xuống",
"changes.upload": "Tải file lên thư mục gốc của workspace",
"changes.uploadHere": "Tải file lên đây…",
"changes.uploaded_one": "Đã tải {{count}} file lên {{dir}}",
"changes.uploaded_other": "Đã tải {{count}} file lên {{dir}}",
"changes.uploadFailed": "Không tải lên được {{name}}: {{message}}",
"changes.expandContext": "Hiện thêm dòng xung quanh",
"changes.expandFile": "Mở {{path}}",
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
@@ -186,6 +192,7 @@
"gitBar.pull": "Pull",
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
"gitBar.push": "Push",
"gitBar.nothingToPush": "Không có gì để push — {{upstream}} đã có mọi commit.",
"gitBar.pushHint": "git push -u origin HEAD",
"gitBar.switch": "Nhánh",
"gitBar.switchApply": "Chuyển nhánh",
@@ -200,6 +207,21 @@
"mode.repoHint": "Gắn thẳng thư mục này làm workspace, nguyên trạng — không worktree, không nhánh mới.",
"mode.worktree": "Worktree",
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
"mode.clone": "Clone",
"mode.cloneHint": "Clone một repository qua HTTPS hoặc SSH vào thư mục của bạn, thành workspace mới.",
"clone.urlLabel": "URL repository",
"clone.urlHint": "https://host/owner/repo.git, git@host:owner/repo.git hoặc ssh://… — SSH dùng key riêng của bạn (Cài đặt → Git).",
"clone.parentLabel": "Clone vào thư mục",
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
"clone.nameLabel": "Tên thư mục",
"clone.cloning": "Đang clone…",
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
"clone.username": "Username",
"clone.token": "Mật khẩu hoặc access token",
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
"clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.",
"clone.sshSettings": "Mở Cài đặt → Git",
"moreActions": "Thêm hành động",
"pipelinePicker.label": "Mẫu pipeline",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
@@ -268,6 +290,7 @@
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ {{op}}",
"changes.discardShortConfirm": "Xoá?",
"changes.amend": "Amend",
"changes.amendPushed": "Commit cuối đã được push; amend sẽ viết lại lịch sử đã chia sẻ. Hãy revert nó trong History.",
"changes.amendHint": "Gộp các file đã tick vào commit cuối (chỉ khi chưa push). Không tick file nào thì chỉ sửa message; để trống message để giữ message cũ.",
"changes.amendPlaceholder": "Message mới (trống = giữ message cũ)",
"changes.historyOf": "Lịch sử của nhánh",
@@ -344,6 +367,9 @@
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
"tree.renameSession": "Đổi tên session",
"tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)",
"tree.pinSession": "Ghim lên đầu workspace này",
"tree.unpinSession": "Bỏ ghim",
"tree.pinned": "Đã ghim",
"tree.worktreeSession": "Trong worktree riêng: .claude/worktrees/{{name}}",
"tree.archiveWorktreeTitle": "Lưu trữ và gỡ worktree của session?",
"tree.archiveWorktreeBody": "Giống Claude Desktop, lưu trữ sẽ gỡ worktree .claude/worktrees/{{name}} của session cùng branch của nó. Git sẽ từ chối nếu còn thay đổi chưa commit, và branch có commit chưa merge vào đâu sẽ được giữ lại — không mất gì chưa lưu.",
@@ -423,6 +449,8 @@
"sessionMenu.details": "Chi tiết session",
"sessionMenu.copyId": "Sao chép ID session",
"sessionMenu.archive": "Lưu trữ",
"sessionMenu.pin": "Ghim",
"sessionMenu.unpin": "Bỏ ghim",
"panes.show": "Hiện {{name}} ở đây",
"panes.splitNew.right": "Chia sang phải với terminal mới",
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
+46 -2
View File
@@ -893,6 +893,30 @@
"pet": "Pet",
"petDescription": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
},
"gitIdentity": {
"authorTitle": "Tác giả commit",
"authorName": "Tên",
"authorEmail": "Email",
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
"authorSaved": "Đã lưu tác giả commit.",
"title": "Danh tính git của bạn",
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
"descriptionHost": "SSH key của tài khoản mà AgentHub đang chạy. Add workspace → Clone dùng key này, và bản clone giữ lại cho các lần fetch, pull, push sau. Đăng nhập HTTPS của tài khoản này là mục Git credentials ở trên.",
"sshTitle": "SSH key",
"generate": "Tạo SSH key",
"replace": "Thay key",
"replaceConfirm": "Thay SSH key? Các bản clone dùng key cũ sẽ không hoạt động cho tới khi public key mới được thêm vào host.",
"keyMade": "SSH key đã sẵn sàng — hãy thêm public key vào git host của bạn.",
"remove": "Xoá",
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
"publicKey": "Public key",
"copy": "Copy public key",
"addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys). Private key không bao giờ rời khỏi server.",
"noKey": "Chưa có SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
"httpsTitle": "Đăng nhập HTTPS",
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại."
},
"gitCreds": {
"title": "Git credentials",
"description": "Mỗi host một tài khoản, dùng chung cho mọi người dùng AgentHub này. Token đã lưu sẽ không hiển thị lại.",
@@ -1061,6 +1085,14 @@
"agenthub": "Worktree do AgentHub quản lý",
"agenthubDesc": "Worktree AgentHub tạo dưới thư mục gốc workspace của nó."
},
"fonts": {
"title": "Chữ & font",
"description": "Ngôn ngữ, cỡ chữ và font của terminal. Lưu theo tài khoản của bạn."
},
"sessionsView": {
"title": "Phiên làm việc",
"description": "Cách mở một session mới và những session nào nằm trên thanh tab. Lưu theo tài khoản của bạn."
},
"language": {
"title": "Ngôn ngữ",
"description": "Ngôn ngữ của toàn bộ dashboard, được lưu vào tài khoản của bạn.",
@@ -1076,9 +1108,20 @@
"label": "Tab của mọi workspace",
"labelDesc": "Giữ session đang chạy của mọi workspace thành tab, và trộn chúng trong một tab chia đôi. Chọn một tab sẽ chuyển sang workspace của nó. Tắt: chỉ session của workspace đang chọn."
},
"textSize": {
"title": "Cỡ chữ",
"description": "Độ lớn chữ của dashboard — menu, danh sách, chat và cài đặt. Lưu vào tài khoản của bạn. Ctrl + / Ctrl − của trình duyệt vẫn phóng to toàn bộ thêm trên mức này.",
"label": "Chữ giao diện",
"names": {
"14": "Nhỏ",
"16": "Mặc định",
"17": "Lớn",
"18": "Rất lớn"
}
},
"terminalFont": {
"title": "Font terminal",
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Lưu vào tài khoản của bạn.",
"description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
"label": "Font",
"names": {
"jetbrains": "JetBrains Mono",
@@ -1086,7 +1129,8 @@
"system": "Font hệ thống"
},
"systemNote": "Font độ rộng cố định của trình duyệt. Font này có thể thiếu một số chữ tiếng Việt (ấ, ế, ố…), khi đó chữ sẽ bị lệch ô.",
"preview": "Xem trước"
"preview": "Xem trước",
"sizeLabel": "Cỡ chữ"
},
"rowMenu": {
"copyHost": "Sao chép host",
+38 -2
View File
@@ -563,7 +563,7 @@
reads it; no typography plugin. */
.md-preview {
color: rgb(var(--fg-secondary));
font-size: 13px;
font-size: 0.8125rem;
line-height: 1.6;
overflow-wrap: anywhere;
}
@@ -650,7 +650,7 @@
.md-preview pre,
.md-preview .md-frontmatter {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
padding: 0.75em 1em;
border-radius: 6px;
@@ -711,3 +711,39 @@
.xterm-viewport::-webkit-scrollbar {
display: none;
}
/* Settings → Appearance → Text size (lib/fontSize.ts) sets the ROOT font size.
Tailwind's own sizes are rem and follow it; these are the `text-[Npx]`
classes the app uses, re-stated in rem (identical at the default 16px) so
the small labels grow with everything else. After `@tailwind utilities`,
same specificity, so these win. A new `text-[Npx]` value belongs here too. */
.text-\[9px\] {
font-size: 0.5625rem;
}
.text-\[10px\] {
font-size: 0.625rem;
}
.text-\[10\.5px\] {
font-size: 0.65625rem;
}
.text-\[11px\] {
font-size: 0.6875rem;
}
.text-\[12px\] {
font-size: 0.75rem;
}
.text-\[12\.5px\] {
font-size: 0.78125rem;
}
.text-\[13px\] {
font-size: 0.8125rem;
}
.text-\[15px\] {
font-size: 0.9375rem;
}
.text-\[16px\] {
font-size: 1rem;
}
.text-\[18px\] {
font-size: 1.125rem;
}
+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);
}
+10
View File
@@ -60,6 +60,7 @@ import { CodexConfig } from "./CodexConfig";
import { AiToolConfig } from "./AiToolConfig";
import { AiProxyConfig } from "./AiProxyConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { GitIdentitySettings } from "../components/settings/GitIdentitySettings";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { AccountSettings } from "../components/settings/AccountSettings";
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
@@ -400,6 +401,15 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<GitCredentialsConfig />
</section>
)}
{/* The user's own SSH key / HTTPS logins (S3: their own home). */}
{(isAdmin || signedIn) && (
<section id="git-identity" className="scroll-mt-24">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">
{t("gitIdentity.title")}
</h2>
<GitIdentitySettings />
</section>
)}
<div id="worktrees" className="scroll-mt-24">
<WorktreeVisibilitySettings />
</div>