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
@@ -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 })
}
/>
)}
</>