feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
This commit is contained in:
@@ -8,21 +8,21 @@
|
||||
* manually-typed branch name. 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 browsing (`FolderBrowseModal`) — a native folder picker
|
||||
* cannot hand a web page an absolute filesystem path, so browsing is
|
||||
* server-backed instead. The worktree lane returned is `status: "provisioning"`:
|
||||
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
|
||||
* opened by the server, the in-app `FolderBrowseModal` where it cannot be).
|
||||
* The worktree lane returned is `status: "provisioning"`:
|
||||
* the route answers before the actual `git worktree add` runs, so this
|
||||
* component polls `GET /api/lanes/:id` until that finishes before running the
|
||||
* auto-setup calls against a cwd that must actually exist on disk first.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, FolderOpen } from "lucide-react";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { api } from "../../lib/api";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
@@ -124,7 +124,6 @@ export function AddLaneModal({
|
||||
const noTools = aiTools.loaded && aiTools.defaultId === null;
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [browseOpen, setBrowseOpen] = useState(false);
|
||||
const [setupResult, setSetupResult] = useState<{
|
||||
profile: "scaffolded" | "skipped" | "failed";
|
||||
mcp: "ok" | "failed";
|
||||
@@ -306,23 +305,18 @@ export function AddLaneModal({
|
||||
}
|
||||
};
|
||||
|
||||
// ConfirmModal re-focuses its Cancel button in an effect keyed on `onCancel`'s
|
||||
// identity. Every keystroke in the fields below re-renders this component; an
|
||||
// inline `() => {...}` handed to `onCancel` would get a new identity each
|
||||
// time, re-running that effect and yanking focus off the field being typed
|
||||
// into after the very first character. useCallback keeps the identity stable
|
||||
// across renders so only mount/unmount (and a real onClose change) refocuses.
|
||||
// Escape, the backdrop, the X and Cancel all land here. A folder browser
|
||||
// open on top answers its own Escape and backdrop (PathPickButton).
|
||||
const handleCancel = useCallback(() => {
|
||||
// ConfirmModal's own Escape/backdrop/X handling calls this directly.
|
||||
// While the folder browser is open on top of it, that dismissal should
|
||||
// close only the browser, not both modals at once.
|
||||
if (browseOpen) {
|
||||
setBrowseOpen(false);
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
}, [onClose, browseOpen]);
|
||||
}, [onClose]);
|
||||
|
||||
// Cancel takes focus on open, the safer default.
|
||||
const titleId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
useDialog(open, panelRef, handleCancel, cancelRef);
|
||||
|
||||
// A summary on screen means the lane already exists and a step failed, so
|
||||
// the confirm button has no second creation to run: it closes instead. That
|
||||
@@ -341,336 +335,369 @@ export function AddLaneModal({
|
||||
);
|
||||
const freeBranches = (branches || []).filter((b) => !busyBranches.has(b));
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Its own shell, not ConfirmModal: that one lays its body beside the title in
|
||||
// the X button's row, which left this form narrower than its own footer.
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={t("addLane")}
|
||||
confirmLabel={done ? t("addLaneSetupClose") : t("add")}
|
||||
cancelLabel={t("destructive.cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={disabled}
|
||||
onConfirm={done ? handleCancel : submit}
|
||||
onCancel={handleCancel}
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={handleCancel}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={mode === "repo"}
|
||||
label={t("mode.repo")}
|
||||
title={t("mode.repoHint")}
|
||||
onClick={() => setMode("repo")}
|
||||
/>
|
||||
<Seg
|
||||
active={mode === "worktree"}
|
||||
label={t("mode.worktree")}
|
||||
title={t("mode.worktreeHint")}
|
||||
onClick={() => setMode("worktree")}
|
||||
/>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="relative flex max-h-[90vh] w-full max-w-lg flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
|
||||
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
|
||||
{t("addLane")}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="btn-icon -mr-1"
|
||||
aria-label={t("destructive.cancel")}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</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")}{" "}
|
||||
<a href="/settings/agents" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={toolListOpen}
|
||||
disabled={toolChoices.length < 2}
|
||||
onClick={() => setToolListOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
|
||||
>
|
||||
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
|
||||
{tool === defaultTool && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
{t("addLaneToolDefaultBadge")}
|
||||
</span>
|
||||
)}
|
||||
{toolChoices.length > 1 && (
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{toolListOpen && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("addLaneToolHint")}
|
||||
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
|
||||
>
|
||||
{toolChoices.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
aria-pressed={x.id === tool}
|
||||
onClick={() => pickTool(x.id)}
|
||||
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
x.id === tool
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{x.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CwdAutocomplete
|
||||
inputId="add-lane-repo"
|
||||
value={sourceRepo}
|
||||
onChange={setSourceRepo}
|
||||
suggestions={cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBrowseOpen(true)}
|
||||
title={t("browse.title")}
|
||||
className="flex items-center gap-1 rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{t("browse.button")}
|
||||
</button>
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={mode === "repo"}
|
||||
label={t("mode.repo")}
|
||||
title={t("mode.repoHint")}
|
||||
onClick={() => setMode("repo")}
|
||||
/>
|
||||
<Seg
|
||||
active={mode === "worktree"}
|
||||
label={t("mode.worktree")}
|
||||
title={t("mode.worktreeHint")}
|
||||
onClick={() => setMode("worktree")}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What this repo already has checked out. Without it the form asked
|
||||
<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")}{" "}
|
||||
<a href="/settings/agents" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={toolListOpen}
|
||||
disabled={toolChoices.length < 2}
|
||||
onClick={() => setToolListOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
|
||||
>
|
||||
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
|
||||
{tool === defaultTool && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
{t("addLaneToolDefaultBadge")}
|
||||
</span>
|
||||
)}
|
||||
{toolChoices.length > 1 && (
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{toolListOpen && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("addLaneToolHint")}
|
||||
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
|
||||
>
|
||||
{toolChoices.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
aria-pressed={x.id === tool}
|
||||
onClick={() => pickTool(x.id)}
|
||||
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
x.id === tool
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{x.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CwdAutocomplete
|
||||
inputId="add-lane-repo"
|
||||
value={sourceRepo}
|
||||
onChange={setSourceRepo}
|
||||
suggestions={cwdSuggestions}
|
||||
/>
|
||||
</div>
|
||||
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||
</p>
|
||||
</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. */}
|
||||
{mode === "worktree" && worktrees.length > 0 && (
|
||||
<div
|
||||
data-testid="add-lane-worktrees"
|
||||
className="rounded-md border border-border-light bg-surface-0 p-2"
|
||||
>
|
||||
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
|
||||
{t("addLaneWorktreesTitle", { count: worktrees.length })}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{worktrees.slice(0, 8).map((w) => (
|
||||
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
|
||||
<span className="shrink-0 font-mono text-fg-primary">
|
||||
⑂ {w.branch || t("addLaneWorktreeDetached")}
|
||||
</span>
|
||||
<span className="truncate text-fg-muted" title={w.path}>
|
||||
{w.path}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{worktrees.length > 8 && (
|
||||
<li className="text-[10px] text-fg-muted">
|
||||
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{mode === "worktree" && worktrees.length > 0 && (
|
||||
<div
|
||||
data-testid="add-lane-worktrees"
|
||||
className="rounded-md border border-border-light bg-surface-0 p-2"
|
||||
>
|
||||
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
|
||||
{t("addLaneWorktreesTitle", { count: worktrees.length })}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{worktrees.slice(0, 8).map((w) => (
|
||||
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
|
||||
<span className="shrink-0 font-mono text-fg-primary">
|
||||
⑂ {w.branch || t("addLaneWorktreeDetached")}
|
||||
</span>
|
||||
<span className="truncate text-fg-muted" title={w.path}>
|
||||
{w.path}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{worktrees.length > 8 && (
|
||||
<li className="text-[10px] text-fg-muted">
|
||||
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
|
||||
{t("addLaneTitleLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("addLaneTitlePlaceholder")}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
|
||||
{t("addLaneTitleLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("addLaneTitlePlaceholder")}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{mode === "worktree" && branchesError && !branches && (
|
||||
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
||||
)}
|
||||
|
||||
{mode === "worktree" && branchesError && !branches && (
|
||||
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
||||
)}
|
||||
|
||||
{mode === "worktree" && branches && (
|
||||
<div data-testid="add-lane-branch-section" className="space-y-2">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={branchMode === "new"}
|
||||
label={t("addLaneBranchModeNew")}
|
||||
onClick={() => {
|
||||
setBranchMode("new");
|
||||
setBranch("");
|
||||
}}
|
||||
/>
|
||||
<Seg
|
||||
active={branchMode === "existing"}
|
||||
label={t("addLaneBranchModeExisting")}
|
||||
onClick={() => {
|
||||
setBranchMode("existing");
|
||||
// Land on something usable rather than on an empty select
|
||||
// that looks like a choice was already made.
|
||||
setBranch((current) =>
|
||||
freeBranches.includes(current) ? current : freeBranches[0] || ""
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* The third answer to "which branch": none. A small or personal
|
||||
{mode === "worktree" && branches && (
|
||||
<div data-testid="add-lane-branch-section" className="space-y-2">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={branchMode === "new"}
|
||||
label={t("addLaneBranchModeNew")}
|
||||
onClick={() => {
|
||||
setBranchMode("new");
|
||||
setBranch("");
|
||||
}}
|
||||
/>
|
||||
<Seg
|
||||
active={branchMode === "existing"}
|
||||
label={t("addLaneBranchModeExisting")}
|
||||
onClick={() => {
|
||||
setBranchMode("existing");
|
||||
// Land on something usable rather than on an empty select
|
||||
// that looks like a choice was already made.
|
||||
setBranch((current) =>
|
||||
freeBranches.includes(current) ? current : freeBranches[0] || ""
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* The third answer to "which branch": none. A small or personal
|
||||
project usually wants the lane to BE the repo, working on
|
||||
whatever is checked out (`main`) — which is exactly Repo
|
||||
mode, so this jumps there rather than inventing a second way
|
||||
to do it. git cannot check `main` out in a second worktree
|
||||
while the repo itself holds it, so a worktree lane could not
|
||||
offer this even if it wanted to. */}
|
||||
<Seg
|
||||
active={false}
|
||||
label={t("addLaneBranchModeCurrent")}
|
||||
title={t("addLaneBranchModeCurrentHint")}
|
||||
onClick={() => setMode("repo")}
|
||||
/>
|
||||
</div>
|
||||
<Seg
|
||||
active={false}
|
||||
label={t("addLaneBranchModeCurrent")}
|
||||
title={t("addLaneBranchModeCurrentHint")}
|
||||
onClick={() => setMode("repo")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{branchMode === "new" ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
// No commit, nothing to fork: the server refuses this with
|
||||
// EEMPTYREPO, so the one way forward is offered instead.
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("repo")}
|
||||
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||
{branchMode === "new" ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
// No commit, nothing to fork: the server refuses this with
|
||||
// EEMPTYREPO, so the one way forward is offered instead.
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("repo")}
|
||||
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("addLaneNoBranchesUseRepo")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
value={base}
|
||||
onChange={(e) => setBase(e.target.value)}
|
||||
className="select w-full !py-1.5 !text-xs"
|
||||
>
|
||||
{t("addLaneNoBranchesUseRepo")}
|
||||
</button>
|
||||
</div>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className="mb-1 block text-xs text-fg-secondary"
|
||||
htmlFor="add-lane-branch"
|
||||
>
|
||||
{t("addLaneBranchLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
// Neutral on purpose: an empty field lets the SERVER name
|
||||
// the branch, under a LANE_BRANCH_PREFIX this form cannot
|
||||
// see, so any `feat/…` preview here could be wrong.
|
||||
placeholder={t("addLaneBranchPlaceholder")}
|
||||
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("addLaneBranchHintAuto")}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label
|
||||
className="mb-1 block text-xs text-fg-secondary"
|
||||
htmlFor="add-lane-existing"
|
||||
>
|
||||
{t("addLaneBranchExistingLabel")}
|
||||
</label>
|
||||
{freeBranches.length === 0 ? (
|
||||
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
value={base}
|
||||
onChange={(e) => setBase(e.target.value)}
|
||||
className="select w-full !py-1.5 !text-xs"
|
||||
id="add-lane-existing"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="select w-full !py-1.5 font-mono !text-xs"
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
{branches.map((b) => {
|
||||
const busyAt = busyBranches.get(b);
|
||||
return (
|
||||
// Disabled rather than hidden: "why is my branch not
|
||||
// in the list" is a worse question than seeing it
|
||||
// greyed out with the worktree that holds it.
|
||||
<option key={b} value={b} disabled={!!busyAt}>
|
||||
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
|
||||
{t("addLaneBranchLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
// Neutral on purpose: an empty field lets the SERVER name
|
||||
// the branch, under a LANE_BRANCH_PREFIX this form cannot
|
||||
// see, so any `feat/…` preview here could be wrong.
|
||||
placeholder={t("addLaneBranchPlaceholder")}
|
||||
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("addLaneBranchHintAuto")}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-existing">
|
||||
{t("addLaneBranchExistingLabel")}
|
||||
</label>
|
||||
{freeBranches.length === 0 ? (
|
||||
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-existing"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="select w-full !py-1.5 font-mono !text-xs"
|
||||
>
|
||||
{branches.map((b) => {
|
||||
const busyAt = busyBranches.get(b);
|
||||
return (
|
||||
// Disabled rather than hidden: "why is my branch not
|
||||
// in the list" is a worse question than seeing it
|
||||
// greyed out with the worktree that holds it.
|
||||
<option key={b} value={b} disabled={!!busyAt}>
|
||||
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{setupResult && (
|
||||
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
|
||||
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
|
||||
{(
|
||||
[
|
||||
{
|
||||
label: t("addLaneSetupProfile"),
|
||||
ok: setupResult.profile !== "failed",
|
||||
skipped: setupResult.profile === "skipped",
|
||||
},
|
||||
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||||
] as const
|
||||
).map((row) => (
|
||||
<p key={row.label} className="flex items-center gap-1.5 text-[11px] text-fg-primary">
|
||||
<span
|
||||
className={
|
||||
"skipped" in row && row.skipped
|
||||
? "text-fg-muted"
|
||||
: row.ok
|
||||
? "text-status-success"
|
||||
: "text-status-danger"
|
||||
}
|
||||
{setupResult && (
|
||||
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
|
||||
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
|
||||
{(
|
||||
[
|
||||
{
|
||||
label: t("addLaneSetupProfile"),
|
||||
ok: setupResult.profile !== "failed",
|
||||
skipped: setupResult.profile === "skipped",
|
||||
},
|
||||
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||||
] as const
|
||||
).map((row) => (
|
||||
<p
|
||||
key={row.label}
|
||||
className="flex items-center gap-1.5 text-[11px] text-fg-primary"
|
||||
>
|
||||
{"skipped" in row && row.skipped ? "–" : row.ok ? "✓" : "✗"}
|
||||
</span>
|
||||
{row.label}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
"skipped" in row && row.skipped
|
||||
? "text-fg-muted"
|
||||
: row.ok
|
||||
? "text-status-success"
|
||||
: "text-status-danger"
|
||||
}
|
||||
>
|
||||
{"skipped" in row && row.skipped ? "–" : row.ok ? "✓" : "✗"}
|
||||
</span>
|
||||
{row.label}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<button
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={done ? handleCancel : submit}
|
||||
disabled={busy || disabled}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{done ? t("addLaneSetupClose") : t("add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FolderBrowseModal
|
||||
open={browseOpen}
|
||||
initialPath={sourceRepo.trim() || undefined}
|
||||
onSelect={setSourceRepo}
|
||||
onClose={() => setBrowseOpen(false)}
|
||||
/>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* actions; click one to open it
|
||||
* review — every file's full diff, stacked, for reading a whole change-set
|
||||
* file — one file, as a diff or as its whole content (issue #35); a
|
||||
* whole markdown file can also be read rendered (Preview)
|
||||
* whole markdown file can also be read rendered (Preview), and an
|
||||
* image file is shown as the picture (`GET …/git/raw`)
|
||||
* history — recent commits; opening one shows that commit's diff
|
||||
* browse — quick-open by path, or the repo's tracked file tree (the
|
||||
* Workspace right panel's Files tab; the other four are its
|
||||
@@ -131,6 +132,33 @@ function Delta({
|
||||
);
|
||||
}
|
||||
|
||||
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
|
||||
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
|
||||
|
||||
/** An image file, shown as itself; a broken one (deleted, too big) says so. */
|
||||
function ImageView({ src, path }: { src: string; path: string }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => setFailed(false), [src]);
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-file-image"
|
||||
className="flex min-h-0 flex-1 items-center justify-center overflow-auto rounded border border-border-light bg-surface-0 p-4"
|
||||
>
|
||||
{failed ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.imageFailed")}</p>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt={path}
|
||||
onError={() => setFailed(true)}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Why a whole-file read came back without an edit stamp (`sha`): the i18n
|
||||
* key naming it, so the reader is never left with a button that does nothing. */
|
||||
function noEditReason(text: { binary?: boolean; truncated: boolean }) {
|
||||
@@ -482,7 +510,7 @@ export function LaneChangesPanel({
|
||||
(text: string) => {
|
||||
const runId = pasteToLane(laneId, text);
|
||||
if (runId) {
|
||||
const slot = Number(/-([1-3])$/.exec(runId)?.[1] ?? 0);
|
||||
const slot = Number(/-([1-9])$/.exec(runId)?.[1] ?? 0);
|
||||
toast.success(t("changes.commentPasted", { tab: slot + 1 }));
|
||||
}
|
||||
return runId !== null;
|
||||
@@ -495,6 +523,8 @@ export function LaneChangesPanel({
|
||||
const [range, setRange] = useState<LaneChangeRange>("work");
|
||||
const [mode, setMode] = useState<Mode>("overview");
|
||||
const [openFile, setOpenFile] = useState<string | null>(null);
|
||||
// An image opens as a picture, never as text or a binary diff.
|
||||
const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null;
|
||||
const [sha, setSha] = useState<string | null>(null);
|
||||
const [context, setContext] = useState(CONTEXT_STEPS[0]);
|
||||
const [layout, setLayout] = useState<DiffLayout>("unified");
|
||||
@@ -1683,7 +1713,7 @@ export function LaneChangesPanel({
|
||||
}}
|
||||
/>
|
||||
) : mode === "file" ? (
|
||||
current || (whole && text) ? (
|
||||
current || (whole && text) || openImage ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="mb-1 flex items-baseline gap-1.5">
|
||||
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
@@ -1714,6 +1744,7 @@ export function LaneChangesPanel({
|
||||
only the diff, which has no whole text to click, keeps
|
||||
an Edit button. */}
|
||||
{range !== "commit" &&
|
||||
!openImage &&
|
||||
(draft === null && whole && text && !text.sha ? (
|
||||
<span
|
||||
data-testid="lane-file-noedit"
|
||||
@@ -1766,7 +1797,16 @@ export function LaneChangesPanel({
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
{whole && draft !== null ? (
|
||||
{openImage ? (
|
||||
<ImageView
|
||||
path={openImage}
|
||||
src={api.lanes.gitRawUrl(
|
||||
target,
|
||||
openImage,
|
||||
range === "commit" && sha ? sha : "work"
|
||||
)}
|
||||
/>
|
||||
) : whole && draft !== null ? (
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
data-testid="lane-file-editor"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file The top of the Changes panel: the lane's working-copy line (branch,
|
||||
* head, subject, and the uncommitted counts that open a per-file hover panel)
|
||||
* and the git action bar under it (`LaneGitBar`).
|
||||
* head, subject, and the uncommitted counts) and the git action bar under it
|
||||
* (`LaneGitBar`); plus the same counts drawn vertical on the CLOSED Changes
|
||||
* rail (`LaneGitRailCounts`), where hovering them opens the per-file panel —
|
||||
* the open panel lists those files itself, so its header chip opens nothing.
|
||||
*
|
||||
* It used to sit in the lane card, a column away from the diff it describes —
|
||||
* a pull there changed files the Changes panel only noticed on its next poll.
|
||||
@@ -101,6 +103,7 @@ function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
anchor,
|
||||
side = "below",
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
@@ -108,6 +111,8 @@ function LaneGitFilesPanel({
|
||||
hidden: number;
|
||||
/** The counts button this panel hangs off. */
|
||||
anchor: HTMLElement | null;
|
||||
/** `left` beside a chip on the right-edge rail, where below would cover it. */
|
||||
side?: "below" | "left";
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: () => void;
|
||||
}) {
|
||||
@@ -122,13 +127,20 @@ function LaneGitFilesPanel({
|
||||
const a = anchor.getBoundingClientRect();
|
||||
const p = panelRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
if (side === "left") {
|
||||
setPos({
|
||||
top: Math.max(8, Math.min(a.top, window.innerHeight - p.height - 8)),
|
||||
left: Math.max(8, a.left - p.width - gap),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const top =
|
||||
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
|
||||
setPos({
|
||||
top: Math.max(8, top),
|
||||
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
|
||||
});
|
||||
}, [anchor, files]);
|
||||
}, [anchor, files, side]);
|
||||
// Totals are over the LISTED files only — the cap means the rest are unknown,
|
||||
// and a total that silently omits them would be a wrong number rather than a
|
||||
// partial one. `hidden` says how many are missing.
|
||||
@@ -204,6 +216,95 @@ function LaneGitFilesPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The uncommitted counts as a chip. With `details` it opens the per-file panel
|
||||
* on hover, focus or click — the closed Changes rail, `vertical`, where nothing
|
||||
* else lists the files. In the open panel the list below already does, so the
|
||||
* header shows the chip alone.
|
||||
*/
|
||||
export function LaneGitCounts({
|
||||
git,
|
||||
vertical = false,
|
||||
details = true,
|
||||
}: {
|
||||
git: Extract<LaneGitFacts, { available: true }>;
|
||||
vertical?: boolean;
|
||||
details?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
// Closing is DELAYED: the panel is portalled away from the trigger, so the
|
||||
// pointer leaves the trigger before it can land on the panel it is heading
|
||||
// for. Any enter — trigger or panel — cancels the pending close.
|
||||
const closeTimer = useRef<number | undefined>(undefined);
|
||||
const openFiles = useCallback(() => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
const closeFiles = useCallback(() => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
closeTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
||||
|
||||
if (git.dirty === 0 && git.untracked === 0) return null;
|
||||
const label = t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked });
|
||||
const chip = `shrink-0 rounded-full border border-status-warning/40 bg-status-warning/10 text-[10.5px] text-status-warning ${
|
||||
vertical ? "px-px py-2 [writing-mode:vertical-rl]" : "px-2 py-px"
|
||||
}`;
|
||||
if (!details) {
|
||||
return (
|
||||
<span data-testid="lane-git-counts" className={chip}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={setAnchor}
|
||||
data-testid="lane-git-counts"
|
||||
aria-expanded={filesOpen}
|
||||
onMouseEnter={openFiles}
|
||||
onMouseLeave={closeFiles}
|
||||
// Focus and click open it too: hover is not a gesture a
|
||||
// keyboard or a touchscreen has.
|
||||
onFocus={openFiles}
|
||||
onBlur={closeFiles}
|
||||
// Click only OPENS: hover (or a tap's focus) has already opened it,
|
||||
// and a toggle here shut it again on the same gesture. Leaving,
|
||||
// blurring or Escape closes it.
|
||||
onClick={openFiles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setFilesOpen(false);
|
||||
}}
|
||||
className={`cursor-help ${chip}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{filesOpen && git.files?.length > 0 && (
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
anchor={anchor}
|
||||
side={vertical ? "left" : "below"}
|
||||
onPointerEnter={openFiles}
|
||||
onPointerLeave={closeFiles}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The counts on the closed Changes rail: its own facts read, polled only
|
||||
* while the rail is shown (the open panel owns the read otherwise). */
|
||||
export function LaneGitRailCounts({ laneId, session }: { laneId: number; session?: string }) {
|
||||
const [git] = useLaneGitFacts(laneId, session);
|
||||
return git?.available ? <LaneGitCounts git={git} vertical /> : null;
|
||||
}
|
||||
|
||||
export function LaneGitHeader({
|
||||
laneId,
|
||||
session,
|
||||
@@ -220,23 +321,6 @@ export function LaneGitHeader({
|
||||
/** See `LaneGitBar`'s prop of the same name. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
// Closing is DELAYED: the panel is portalled away from the trigger, so the
|
||||
// pointer leaves the trigger before it can land on the panel it is heading
|
||||
// for. Any enter — trigger or panel — cancels the pending close.
|
||||
const filesCloseTimer = useRef<number | undefined>(undefined);
|
||||
const openFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
const closeFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
|
||||
|
||||
if (!git?.available) return null;
|
||||
const lead = (
|
||||
<>
|
||||
@@ -255,30 +339,7 @@ export function LaneGitHeader({
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
ref={setCountsAnchor}
|
||||
data-testid="lane-git-counts"
|
||||
aria-expanded={filesOpen}
|
||||
onMouseEnter={openFiles}
|
||||
onMouseLeave={closeFiles}
|
||||
// Focus and click open it too: hover is not a gesture a
|
||||
// keyboard or a touchscreen has.
|
||||
onFocus={openFiles}
|
||||
onBlur={closeFiles}
|
||||
// Click only OPENS: hover (or a tap's focus) has already opened it,
|
||||
// and a toggle here shut it again on the same gesture. Leaving,
|
||||
// blurring or Escape closes it.
|
||||
onClick={openFiles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setFilesOpen(false);
|
||||
}}
|
||||
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
|
||||
>
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</button>
|
||||
)}
|
||||
<LaneGitCounts git={git} details={false} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
@@ -291,15 +352,6 @@ export function LaneGitHeader({
|
||||
onNewLane={onNewLane}
|
||||
lead={lead}
|
||||
/>
|
||||
{filesOpen && git.files?.length > 0 && (
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
anchor={countsAnchor}
|
||||
onPointerEnter={openFiles}
|
||||
onPointerLeave={closeFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file WorkspaceHome — what `/workspace?lane=<id>` opens on, shaped like a
|
||||
* Claude Desktop project page: the name, a prompt box that starts a NEW
|
||||
* session in the first free pane slot (0–3, `POST /api/lanes/:id/start`) and
|
||||
* session in the first free slot (0–9, `POST /api/lanes/:id/start`) and
|
||||
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
|
||||
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
|
||||
* `ConsoleTabs.tsx`). Inside
|
||||
@@ -82,6 +82,7 @@ export {
|
||||
startInFreeTabDetailed,
|
||||
} from "./startSession";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
@@ -110,9 +111,8 @@ function HomeBody({
|
||||
onRenamed,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Row-menu items the page carries out (its modals); rename is done here.
|
||||
* `started` comes with the run it opened. */
|
||||
onMenuAction: (action: WorkspaceMenuAction, runId?: string) => void;
|
||||
/** Row-menu items the page carries out (its modals); rename is done here. */
|
||||
onMenuAction: (action: WorkspaceMenuAction) => void;
|
||||
/** Called after a rename, so the page re-reads the lane list. */
|
||||
onRenamed?: () => void;
|
||||
}) {
|
||||
@@ -957,6 +957,7 @@ function HomeBody({
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
|
||||
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
|
||||
{t("home.add")}
|
||||
</button>
|
||||
@@ -1016,10 +1017,10 @@ function HomeBody({
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("home.memory")}
|
||||
// shrink-0: a horizontal scroller's min-height is 0, so in this
|
||||
// height-capped column a long file squeezed the row and clipped
|
||||
// the tabs. The panel below is the one part that scrolls.
|
||||
className="mb-3 flex shrink-0 gap-1.5 overflow-x-auto border-b border-border/60 pb-2 no-scrollbar"
|
||||
// Wraps, so every file is reachable without a horizontal wheel (a
|
||||
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
|
||||
// with a visible bar. shrink-0: a long file must not squeeze it.
|
||||
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
|
||||
>
|
||||
{memory.map((f, i) => (
|
||||
<button
|
||||
@@ -1124,9 +1125,7 @@ function HomeBody({
|
||||
x={menuAt.x}
|
||||
y={menuAt.y}
|
||||
onClose={() => setMenuAt(null)}
|
||||
onAction={(a, runId) =>
|
||||
a === "rename" ? setTitle(lane.title || "") : onMenuAction(a, runId)
|
||||
}
|
||||
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
|
||||
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
|
||||
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
|
||||
* the lane row. Only a workspace row's own drag (`LANE_DRAG_TYPE`) is taken —
|
||||
* a file, a tab or a text dragged over is not a reorder. F2 renames too; the
|
||||
* the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list
|
||||
* (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the
|
||||
* dot's meaning is screen-reader text, and an idle dot is a ring.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -20,7 +20,7 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
/** What a workspace row's drag carries: its index in the list. */
|
||||
export const LANE_DRAG_TYPE = "application/x-agenthub-lane";
|
||||
const isLaneDrag = (e: { dataTransfer: DataTransfer | null }) =>
|
||||
export const isLaneDrag = (e: { dataTransfer: DataTransfer | null }) =>
|
||||
!!e.dataTransfer?.types?.includes(LANE_DRAG_TYPE);
|
||||
|
||||
/** The row's dot: the lane's liveness, plus a running pulse and needs-you. */
|
||||
@@ -78,7 +78,6 @@ export function WorkspaceRow({
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [draft, setDraft] = useState("");
|
||||
const [dropTarget, setDropTarget] = useState(false);
|
||||
const name = workspaceName(lane);
|
||||
const openMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -92,38 +91,20 @@ export function WorkspaceRow({
|
||||
return (
|
||||
<div
|
||||
data-testid={`workspace-row-${lane.id}`}
|
||||
data-drop-target={dropTarget ? "true" : undefined}
|
||||
data-lane-index={index}
|
||||
draggable={!renaming}
|
||||
onDragStart={(e) => {
|
||||
// Firefox starts no drag at all without a setData call.
|
||||
e.dataTransfer.setData(LANE_DRAG_TYPE, String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!isLaneDrag(e)) return;
|
||||
e.preventDefault();
|
||||
setDropTarget(true);
|
||||
}}
|
||||
onDragLeave={() => setDropTarget(false)}
|
||||
onDragEnd={() => setDropTarget(false)}
|
||||
onDrop={(e) => {
|
||||
setDropTarget(false);
|
||||
if (!isLaneDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
||||
// Number("") is 0: an empty payload must not move the first row.
|
||||
const from = raw === "" ? NaN : Number(raw);
|
||||
if (Number.isInteger(from)) onReorder(from, index);
|
||||
}}
|
||||
onContextMenu={openMenu}
|
||||
className={`group flex items-center gap-1 rounded-md pl-1 pr-1 transition-colors ${
|
||||
dropTarget
|
||||
? "bg-accent/20"
|
||||
: selected
|
||||
? "bg-accent/10"
|
||||
: lane.needs_action
|
||||
? "bg-status-warning/10 hover:bg-status-warning/20"
|
||||
: "hover:bg-surface-3"
|
||||
selected
|
||||
? "bg-accent/10"
|
||||
: lane.needs_action
|
||||
? "bg-status-warning/10 hover:bg-status-warning/20"
|
||||
: "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
/**
|
||||
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
|
||||
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
||||
* existing call: Start (`POST /api/lanes/:id/start`, the free tab the server
|
||||
* picks — then the session opens, `onAction("started", runId)`) / Stop
|
||||
* (`stop`, every tab of the workspace — counted from `GET /api/run` when the
|
||||
* menu opens, and confirmed: "Stop N sessions?"),
|
||||
* Rename (the row's inline field), New workspace here (the Add modal prefilled),
|
||||
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||
* existing call: Rename (the row's inline field), New workspace here (the
|
||||
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||
* managed lane. The server's guards are the real ones; the menu only hides
|
||||
* what the lane's kind or tool makes meaningless. The shell (position,
|
||||
* Escape / outside-click close) is `ContextMenu`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
||||
|
||||
export function WorkspaceRowMenu({
|
||||
lane,
|
||||
@@ -36,62 +27,12 @@ export function WorkspaceRowMenu({
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
/** Items the tree itself carries out (rename field, modals, selection);
|
||||
* `started` comes with the run it opened. */
|
||||
onAction: (action: WorkspaceMenuAction, runId?: string) => void;
|
||||
/** Items the tree itself carries out (rename field, modals). */
|
||||
onAction: (action: WorkspaceMenuAction) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
// Every live tab of the workspace — slot 0 alone undercounts a split.
|
||||
const [liveCount, setLiveCount] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.run
|
||||
.list()
|
||||
.then(
|
||||
(r) =>
|
||||
alive &&
|
||||
setLiveCount(
|
||||
(r.items ?? []).filter((x) => x.laneId === lane.id && x.status === "running").length
|
||||
)
|
||||
)
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [lane.id]);
|
||||
const live = liveCount === null ? !!lane.run_id || lane.status === "running" : liveCount > 0;
|
||||
|
||||
const stop = async () => {
|
||||
onClose();
|
||||
const n = liveCount ?? 1;
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("menu.stopTitle", { count: n }),
|
||||
message: t("menu.stopBody"),
|
||||
confirmLabel: t("menu.stop"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.lanes.action(lane.id, "stop");
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
const start = async () => {
|
||||
onClose();
|
||||
try {
|
||||
onAction("started", await startInFreeTab(lane.id, { prompt: "" }));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
|
||||
<MenuItem onSelect={() => void (live ? stop() : start())}>
|
||||
{live ? t("menu.stop") : t("menu.start")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
|
||||
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
|
||||
<MenuItem
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
* carrying its session id) opens that tmux pane on the Workspace page
|
||||
* (`?lane=<id>&run=<runId>`, its tab in the session view; a live tab whose
|
||||
* session has no row yet — a bare tab before its first prompt — is listed as
|
||||
* **New session** from the run itself; Ctrl/Cmd+click opens
|
||||
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
|
||||
* which never has a session) as **Terminal** — and under the sessions the last
|
||||
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
|
||||
* click opens a NEW shell tab (a shell cannot be resumed); Ctrl/Cmd+click opens
|
||||
* it in a split beside the focused pane) — attaching, never starting or
|
||||
* resuming anything;
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
@@ -28,10 +31,12 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch } from "lucide-react";
|
||||
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { timeAgo } from "../../lib/format";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
@@ -41,6 +46,8 @@ import { useContextMenu } from "../ContextMenu";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
|
||||
export const SESSION_LIMIT = 6;
|
||||
/** Closed Terminal tabs listed under a workspace's sessions. */
|
||||
export const CLOSED_TERMINALS = 3;
|
||||
|
||||
/**
|
||||
* The live pane holding a session: the run the hooks bound it to
|
||||
@@ -253,12 +260,43 @@ export function WorkspaceSessionList({
|
||||
const laneRuns = liveRuns.filter(
|
||||
(r) => r.laneId === lane.id && r.status === "running" && r.tool !== "shell"
|
||||
);
|
||||
const liveShells = liveRuns.filter(
|
||||
(r) => r.laneId === lane.id && r.status === "running" && r.tool === "shell"
|
||||
);
|
||||
// The lane's own session/status/needs-you move on every hook, which is when
|
||||
// a session can appear or change state, and a tab opening or getting its
|
||||
// session is too — re-read then, not on a timer.
|
||||
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
|
||||
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
|
||||
.join(",")}`;
|
||||
// A Terminal tab closing is when one joins the history.
|
||||
const shellKey = liveShells.map((r) => r.id).join(",");
|
||||
const [closedShells, setClosedShells] = useState<RunHistoryItem[]>([]);
|
||||
useEffect(() => {
|
||||
if (hidden) return;
|
||||
let live = true;
|
||||
api.run
|
||||
// Live ones come back too (isLive): ask for enough to fill the list anyway.
|
||||
.history({ laneId: lane.id, tool: "shell", limit: CLOSED_TERMINALS + 10 })
|
||||
.then(
|
||||
(r) => live && setClosedShells(r.items.filter((x) => !x.isLive).slice(0, CLOSED_TERMINALS))
|
||||
)
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id, shellKey, hidden]);
|
||||
const reopenShell = async (split: boolean) => {
|
||||
try {
|
||||
const runId = await startInFreeTab(lane.id, { shell: true });
|
||||
navigate(
|
||||
`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`,
|
||||
split ? { state: { split: true } } : undefined
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||
@@ -301,11 +339,15 @@ export function WorkspaceSessionList({
|
||||
const fresh =
|
||||
sessions === null || q
|
||||
? []
|
||||
: laneRuns.filter(
|
||||
(r) =>
|
||||
!r.sessionId ||
|
||||
(!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId))
|
||||
);
|
||||
: [
|
||||
...laneRuns.filter(
|
||||
(r) =>
|
||||
!r.sessionId ||
|
||||
(!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId))
|
||||
),
|
||||
// Live Terminal tabs: no session row ever, so listed from the run.
|
||||
...liveShells,
|
||||
];
|
||||
const count = sessions === null ? null : shown.length;
|
||||
useEffect(() => {
|
||||
if (count !== null) onMatches?.(count);
|
||||
@@ -380,7 +422,9 @@ export function WorkspaceSessionList({
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
if (sessions !== null && shown.length === 0 && fresh.length === 0) {
|
||||
// Not during a search, like the live ones: they have no name to match.
|
||||
const closed = q ? [] : closedShells;
|
||||
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
@@ -405,7 +449,7 @@ export function WorkspaceSessionList({
|
||||
)
|
||||
}
|
||||
onContextMenu={(e) => ctx.open(e, r)}
|
||||
title={`${t("tree.newSession")}\n${t("tree.splitHint")}`}
|
||||
title={`${r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}\n${t("tree.splitHint")}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
r.id === selectedRunId
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
@@ -414,11 +458,20 @@ export function WorkspaceSessionList({
|
||||
>
|
||||
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
|
||||
<span className="sr-only">{t("tree.state.live")}</span>
|
||||
<span className="truncate italic">{t("tree.newSession")}</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: r.tool, label: tools.label(r.tool) }}
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
|
||||
/>
|
||||
{r.tool === "shell" ? (
|
||||
<>
|
||||
<span className="truncate">{t("tabs.terminal")}</span>
|
||||
<SquareTerminal aria-hidden className="ml-auto h-3.5 w-3.5 shrink-0 opacity-70" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate italic">{t("tree.newSession")}</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: r.tool, label: tools.label(r.tool) }}
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -521,6 +574,28 @@ export function WorkspaceSessionList({
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{closed.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-terminal-closed-${r.id}`}
|
||||
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
|
||||
title={t("tree.closedTerminal")}
|
||||
className="flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full border border-fg-muted"
|
||||
/>
|
||||
<span className="sr-only">{t("tree.state.idle")}</span>
|
||||
<span className="truncate">{t("tabs.terminal")}</span>
|
||||
<span className="ml-auto shrink-0 text-[11px]">
|
||||
{timeAgo(r.ended_at ?? r.started_at)}
|
||||
</span>
|
||||
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{more > 0 && !q && (
|
||||
<button
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
pickPath: vi.fn(),
|
||||
get: vi.fn(),
|
||||
profileInit: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
@@ -147,6 +148,7 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
vi.mocked(api.lanes.pickPath).mockReset();
|
||||
// Provisioning finishes instantly by default - tests that care about the
|
||||
// provisioning-in-progress race override this per-test.
|
||||
vi.mocked(api.lanes.get)
|
||||
@@ -549,7 +551,45 @@ describe("AddLaneModal — repo mode (adopt)", () => {
|
||||
});
|
||||
|
||||
describe("AddLaneModal — folder browse", () => {
|
||||
const noNativePicker = () =>
|
||||
vi
|
||||
.mocked(api.lanes.pickPath)
|
||||
.mockRejectedValue(Object.assign(new Error("no picker"), { code: "ENODIALOG" }));
|
||||
|
||||
it("fills the path field from the OS's own picker", async () => {
|
||||
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTitle("Browse for a folder"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(document.getElementById("add-lane-repo")).toHaveValue("/Users/tester/app")
|
||||
);
|
||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the in-app browser when no native picker is available", async () => {
|
||||
noNativePicker();
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/Users/tester",
|
||||
parent: null,
|
||||
entries: [],
|
||||
});
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTitle("Browse for a folder"));
|
||||
const browser = await screen.findByRole("dialog", { name: "Browse for a folder" });
|
||||
// Its backdrop closes only the browser, never the modal behind it.
|
||||
await user.click(browser.parentElement!);
|
||||
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
|
||||
noNativePicker();
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/Users/tester",
|
||||
parent: "/Users",
|
||||
@@ -566,6 +606,7 @@ describe("AddLaneModal — folder browse", () => {
|
||||
});
|
||||
|
||||
it("Escape closes only the folder browser, not the whole modal", async () => {
|
||||
noNativePicker();
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/Users/tester",
|
||||
parent: null,
|
||||
|
||||
@@ -34,6 +34,8 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitAction: vi.fn(),
|
||||
gitLog: vi.fn(),
|
||||
gitFile: vi.fn(),
|
||||
gitRawUrl: (id: number, path: string, rev: string) =>
|
||||
`/api/lanes/${id}/git/raw?path=${encodeURIComponent(path)}&rev=${rev}`,
|
||||
gitSaveFile: vi.fn(),
|
||||
gitLs: vi.fn(),
|
||||
gitTree: vi.fn(),
|
||||
@@ -380,6 +382,32 @@ describe("LaneChangesPanel", () => {
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "logo.png", path: "logo.png", dir: false, status: "" }],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "logo.png",
|
||||
rev: "work",
|
||||
binary: true,
|
||||
truncated: false,
|
||||
lines: [],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("logo.png"));
|
||||
const img = within(await screen.findByTestId("lane-file-image")).getByRole("img");
|
||||
expect(img).toHaveAttribute("src", "/api/lanes/7/git/raw?path=logo.png&rev=work");
|
||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-noedit")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file LaneGitHeader.test.tsx
|
||||
* @description Pins the Changes panel's working-copy line: the live branch,
|
||||
* head, subject and uncommitted counts, the per-file hover panel behind the
|
||||
* head, subject and uncommitted counts (no hover there — the panel lists the
|
||||
* files below), the per-file hover panel behind the closed rail's vertical
|
||||
* counts, that a lane switch drops the previous lane's facts at once, and that
|
||||
* an unreadable repo renders nothing rather than an error.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -9,7 +10,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
|
||||
import { LaneGitHeader, LaneGitRailCounts, useLaneGitFacts } from "../LaneGitHeader";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -70,9 +71,9 @@ describe("LaneGitHeader", () => {
|
||||
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
|
||||
});
|
||||
|
||||
it("names the files behind the counts on hover, with each one's line delta", async () => {
|
||||
it("names the files behind the rail's counts on hover, with each one's line delta", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
render(<LaneGitRailCounts laneId={1} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
// Hidden until asked for: the row is a one-line summary until you hover.
|
||||
@@ -98,7 +99,7 @@ describe("LaneGitHeader", () => {
|
||||
|
||||
it("stays open while the pointer is inside the panel", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
render(<LaneGitRailCounts laneId={1} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
@@ -115,12 +116,27 @@ describe("LaneGitHeader", () => {
|
||||
it("says how many files the server-side cap left out", async () => {
|
||||
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
render(<Harness laneId={1} />);
|
||||
render(<LaneGitRailCounts laneId={1} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("shows the counts in the open panel's header without the hover list", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.focus(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the rail's counts vertical", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneGitRailCounts laneId={1} />);
|
||||
expect((await screen.findByTestId("lane-git-counts")).className).toContain("vertical-rl");
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
@@ -221,7 +221,7 @@ describe("SessionResumeButton", () => {
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2");
|
||||
});
|
||||
|
||||
it("says so when the server finds all four panes busy", async () => {
|
||||
it("says so when the server finds all ten sessions in use", async () => {
|
||||
vi.mocked(api.lanes.action).mockRejectedValueOnce(
|
||||
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
|
||||
);
|
||||
@@ -229,7 +229,7 @@ describe("SessionResumeButton", () => {
|
||||
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
|
||||
await user.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("All 10 sessions");
|
||||
});
|
||||
|
||||
it("opens the pane the server names when the session is already live there", async () => {
|
||||
|
||||
@@ -193,8 +193,9 @@ describe("freeSlot / paneRunId", () => {
|
||||
it("picks the first slot no live run of this lane holds", () => {
|
||||
expect(freeSlot(1, [])).toBe(0);
|
||||
expect(freeSlot(1, runs)).toBe(1);
|
||||
const all = [0, 1, 2, 3].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
|
||||
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
|
||||
expect(freeSlot(1, all)).toBeNull();
|
||||
expect(freeSlot(1, all.slice(0, 4))).toBe(4);
|
||||
expect(freeSlot(2, all)).toBe(0);
|
||||
expect(paneRunId(4, 0)).toBe("agenthub-lane-4");
|
||||
expect(paneRunId(4, 2)).toBe("agenthub-lane-4-2");
|
||||
@@ -318,7 +319,7 @@ describe("WorkspaceHome", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("says so when the server finds all four panes busy", async () => {
|
||||
it("says so when the server finds all ten sessions in use", async () => {
|
||||
vi.mocked(api.lanes.action).mockRejectedValueOnce(
|
||||
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
|
||||
);
|
||||
@@ -327,7 +328,7 @@ describe("WorkspaceHome", () => {
|
||||
await screen.findByTestId("workspace-recent-s-old");
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "go");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("All 10 sessions");
|
||||
});
|
||||
|
||||
it("starts nothing on an empty prompt, and only once on a double Enter", async () => {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* @file startSession.ts — starting a session in a workspace's first free tab
|
||||
* (`POST /api/lanes/:id/start` with `slot: "auto"`) and the small pane-slot
|
||||
* helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's row menu
|
||||
* can start a session without pulling the whole home (and react-markdown) into
|
||||
* helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's session
|
||||
* list can use them without pulling the whole home (and react-markdown) into
|
||||
* the main bundle.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import i18n from "../../i18n";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import { MAX_SLOTS } from "../../lib/paneLayout";
|
||||
|
||||
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
||||
export function paneRunId(laneId: number, slot: number): string {
|
||||
@@ -31,7 +32,7 @@ export async function startInFreeTab(
|
||||
|
||||
/** The workspace a pane belongs to, from its tmux name `agenthub-lane-<id>[-slot]`. */
|
||||
export function laneOfRun(runId: string): number | null {
|
||||
const m = /^agenthub-lane-(\d+)(?:-[1-3])?$/.exec(runId);
|
||||
const m = /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(runId);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
@@ -54,10 +55,11 @@ export async function startInFreeTabDetailed(
|
||||
}
|
||||
}
|
||||
|
||||
/** The first pane slot with no live run, or null when all four are busy. */
|
||||
/** The first pane slot with no live run, or null when all ten are busy. */
|
||||
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
|
||||
const busy = new Set(
|
||||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
||||
);
|
||||
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
|
||||
for (let s = 0; s < MAX_SLOTS; s++) if (!busy.has(s)) return s;
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user