Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3daf7ed4cb | |||
| f9869ada6d | |||
| 0fc436dc0c | |||
| cd25791160 | |||
| 2a83551eee | |||
| d0889d5250 |
@@ -32,7 +32,7 @@ export function PetPanel({
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
className="pet-chat flex max-h-[calc(100vh-6rem)] w-[22rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.panel.label", { name })}
|
||||
>
|
||||
@@ -54,7 +54,9 @@ export function PetPanel({
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto">{info}</div>
|
||||
{/* Vertical only: an auto overflow-y makes x scrollable too, and the
|
||||
status chips' row then showed a horizontal scrollbar. */}
|
||||
<div className="min-w-0 overflow-y-auto overflow-x-hidden">{info}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
* the next option whose label starts with it. Focus returns to the trigger on
|
||||
* every close except Tab.
|
||||
*
|
||||
* ## Search — a combobox once the list is long
|
||||
* With more than `SEARCH_FROM` options (or `searchable`), the popup opens with
|
||||
* a search box on top, focused: typing narrows the list to options whose
|
||||
* label or value contains the text (case-insensitive), ↑/↓ move, Enter picks,
|
||||
* Escape closes. The box is `role="combobox"` controlling the listbox through
|
||||
* `aria-activedescendant`. Options may carry a `group`: a heading is drawn
|
||||
* where the group changes.
|
||||
*
|
||||
* ## Consumers
|
||||
* Used on the Run Claude page and webhook settings form wherever a compact
|
||||
* enum picker is needed.
|
||||
@@ -73,7 +81,11 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Check, Search } from "lucide-react";
|
||||
|
||||
/** A list longer than this gets a search box (unless `searchable` says otherwise). */
|
||||
export const SEARCH_FROM = 8;
|
||||
|
||||
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
|
||||
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
|
||||
@@ -95,6 +107,8 @@ export interface SelectOption<T extends string> {
|
||||
icon?: ReactNode;
|
||||
/** Draw a rule above this option: it starts a group of its own. */
|
||||
divider?: boolean;
|
||||
/** The group this option belongs to: a heading is drawn where it changes. */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/** Props for {@link Select}. */
|
||||
@@ -111,6 +125,10 @@ export interface SelectProps<T extends string> {
|
||||
variant?: "field" | "ghost";
|
||||
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
|
||||
label?: string;
|
||||
/** Show the search box: default only for lists longer than `SEARCH_FROM`. */
|
||||
searchable?: boolean;
|
||||
/** The search box's name and placeholder (default: "Search…"). */
|
||||
searchLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,15 +142,26 @@ export function Select<T extends string>({
|
||||
disabled,
|
||||
variant = "field",
|
||||
label,
|
||||
searchable,
|
||||
searchLabel,
|
||||
}: SelectProps<T>) {
|
||||
const { t } = useTranslation("common");
|
||||
const ghost = variant === "ghost";
|
||||
const canSearch = searchable ?? options.length > SEARCH_FROM;
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [openUp, setOpenUp] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||
const typed = useRef({ text: "", at: 0 });
|
||||
// The options shown: all of them, or those the search text matches.
|
||||
const q = canSearch ? query.trim().toLowerCase() : "";
|
||||
const shown = q
|
||||
? options.filter((o) => `${o.label} ${o.value}`.toLowerCase().includes(q))
|
||||
: options;
|
||||
const id = useId();
|
||||
const optionId = (idx: number) => `${id}-opt-${idx}`;
|
||||
|
||||
@@ -150,6 +179,7 @@ export function Select<T extends string>({
|
||||
// above the trigger (288 px = `max-h-72`), and move focus into the list.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery("");
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
if (buttonRef.current) {
|
||||
@@ -159,14 +189,14 @@ export function Select<T extends string>({
|
||||
);
|
||||
setOpenUp(below < 288 && above > below);
|
||||
}
|
||||
listRef.current?.focus({ preventScroll: true });
|
||||
(canSearch ? searchRef.current : listRef.current)?.focus({ preventScroll: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
|
||||
}, [open, active]);
|
||||
}, [open, active, q]);
|
||||
|
||||
const close = (refocus = true) => {
|
||||
setOpen(false);
|
||||
@@ -185,8 +215,12 @@ export function Select<T extends string>({
|
||||
}
|
||||
};
|
||||
|
||||
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const last = options.length - 1;
|
||||
const onListKey = (e: React.KeyboardEvent<HTMLElement>) => {
|
||||
const last = shown.length - 1;
|
||||
// In the search box, keys that edit text stay the box's own.
|
||||
const inSearch = e.currentTarget === searchRef.current;
|
||||
if (inSearch && (e.key === " " || e.key === "Home" || e.key === "End")) return;
|
||||
if (inSearch && e.key === "Enter" && e.nativeEvent.isComposing) return;
|
||||
const move: Record<string, (a: number) => number> = {
|
||||
ArrowDown: (a) => Math.min(last, a + 1),
|
||||
ArrowUp: (a) => Math.max(0, a - 1),
|
||||
@@ -200,7 +234,7 @@ export function Select<T extends string>({
|
||||
setActive(move[e.key]!);
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const opt = options[active];
|
||||
const opt = shown[active];
|
||||
if (opt) choose(opt);
|
||||
} else if (e.key === "Escape") {
|
||||
// Stop here: a Select inside a dialog must not close the dialog too.
|
||||
@@ -209,7 +243,7 @@ export function Select<T extends string>({
|
||||
close();
|
||||
} else if (e.key === "Tab") {
|
||||
close(false);
|
||||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
} else if (!inSearch && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
|
||||
// character cycles through the options that start with it.
|
||||
const now = Date.now();
|
||||
@@ -258,57 +292,100 @@ export function Select<T extends string>({
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
id={`${id}-list`}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
aria-activedescendant={options[active] ? optionId(active) : undefined}
|
||||
onKeyDown={onListKey}
|
||||
onBlur={(e) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
||||
}}
|
||||
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
|
||||
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} flex max-h-72 flex-col overflow-hidden rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 ${
|
||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||
}`}
|
||||
>
|
||||
{options.map((opt, idx) => {
|
||||
const isSelected = opt.value === value;
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<div
|
||||
key={opt.value || "__default__"}
|
||||
id={optionId(idx)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => choose(opt)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
||||
} ${
|
||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{opt.icon}
|
||||
<span
|
||||
className={`text-[11px] flex-1 truncate ${
|
||||
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
||||
{canSearch && (
|
||||
<div className="flex shrink-0 items-center gap-1.5 border-b border-border px-2.5 py-1.5">
|
||||
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||
<input
|
||||
ref={searchRef}
|
||||
role="combobox"
|
||||
aria-label={searchLabel ?? t("select.search")}
|
||||
aria-expanded
|
||||
aria-controls={`${id}-list`}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={shown[active] ? optionId(active) : undefined}
|
||||
placeholder={searchLabel ?? t("select.search")}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={onListKey}
|
||||
onBlur={(e) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null))
|
||||
setOpen(false);
|
||||
}}
|
||||
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={listRef}
|
||||
id={`${id}-list`}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
aria-activedescendant={!canSearch && shown[active] ? optionId(active) : undefined}
|
||||
onKeyDown={onListKey}
|
||||
onBlur={(e) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
||||
}}
|
||||
className="min-h-0 flex-1 overflow-auto py-1 outline-none"
|
||||
>
|
||||
{canSearch && shown.length === 0 && (
|
||||
<div className="px-3 py-1.5 text-[11px] text-fg-muted">{t("select.noMatch")}</div>
|
||||
)}
|
||||
{shown.map((opt, idx) => {
|
||||
const isSelected = opt.value === value;
|
||||
const isActive = idx === active;
|
||||
const heading = opt.group && opt.group !== shown[idx - 1]?.group ? opt.group : null;
|
||||
return (
|
||||
<div key={opt.value || "__default__"} role="none">
|
||||
{heading && (
|
||||
<div
|
||||
role="presentation"
|
||||
className="mt-1 border-t border-border px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-fg-muted"
|
||||
>
|
||||
{heading}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
id={optionId(idx)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => choose(opt)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
||||
} ${
|
||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{opt.icon}
|
||||
<span
|
||||
className={`text-[11px] flex-1 truncate ${
|
||||
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
{opt.hint && (
|
||||
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{opt.hint && (
|
||||
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -559,6 +559,8 @@ export function WebhookSettings() {
|
||||
headerRows: [],
|
||||
})
|
||||
}
|
||||
// A short, fixed list of providers: no search box.
|
||||
searchable={false}
|
||||
options={providers.map((p) => ({ value: p.type, label: p.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,61 @@ describe("Select (listbox button)", () => {
|
||||
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
|
||||
vi.stubGlobal("innerHeight", 2000);
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole("listbox").className).toContain("bottom-full");
|
||||
expect(screen.getByRole("listbox").parentElement!.className).toContain("bottom-full");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("turns a long list into a combobox: type to narrow, Enter picks, groups keep headings", async () => {
|
||||
const onChange = vi.fn();
|
||||
const options = [
|
||||
{ value: "", label: "Switch branch…" },
|
||||
...["develop", "main", "feat/campaign-pause", "fix/campaign-step", "chore/a", "chore/b"].map(
|
||||
(v) => ({ value: v, label: v })
|
||||
),
|
||||
...["origin/feat/campaign-statistic", "origin/x", "origin/y"].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
group: "On the remote",
|
||||
})),
|
||||
];
|
||||
render(<Select value="" onChange={onChange} options={options} label="Switch branch…" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
|
||||
const search = screen.getByRole("combobox", { name: "Search…" });
|
||||
expect(search).toHaveFocus();
|
||||
expect(screen.getByText("On the remote")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(search, { target: { value: "CAMPAIGN" } });
|
||||
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||
"feat/campaign-pause",
|
||||
"fix/campaign-step",
|
||||
"origin/feat/campaign-statistic",
|
||||
]);
|
||||
fireEvent.keyDown(search, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(search, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith("fix/campaign-step");
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: "zzz" } });
|
||||
expect(screen.getByText("No match")).toBeInTheDocument();
|
||||
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" });
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a short list a plain listbox (no search box)", () => {
|
||||
render(
|
||||
<Select
|
||||
value="a"
|
||||
onChange={vi.fn()}
|
||||
options={[
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
]}
|
||||
label="Pick"
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pick" }));
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
expect(screen.getByRole("listbox")).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
import { restoreShelf } from "./LaneShelves";
|
||||
import { BranchesButton } from "./git/BranchesButton";
|
||||
import { Select } from "../Select";
|
||||
|
||||
type Action =
|
||||
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
||||
@@ -132,8 +133,6 @@ export function LaneGitBar({
|
||||
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
// Issue #88: typing narrows the switcher to the branches whose name holds it.
|
||||
const [branchQuery, setBranchQuery] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
// Set when git needed a login for `action`; the form resends that action.
|
||||
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
|
||||
@@ -162,7 +161,6 @@ export function LaneGitBar({
|
||||
setRemoteBranches([]);
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setBranchQuery("");
|
||||
setNewBranch("");
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
@@ -179,26 +177,6 @@ export function LaneGitBar({
|
||||
// A remote branch: switching creates its local tracking branch; it cannot
|
||||
// be deleted here or given a sibling worktree before that.
|
||||
const pickedRemote = remoteBranches.includes(picked);
|
||||
// The switcher's lists, narrowed by the search box (case-insensitive, any
|
||||
// part of the name). The picked branch always stays in its list, so the
|
||||
// select never shows a value it has no option for.
|
||||
const query = branchQuery.trim().toLowerCase();
|
||||
const matches = (b: string) => !query || b.toLowerCase().includes(query) || b === picked;
|
||||
const shownBranches = (branches || []).filter(matches);
|
||||
const shownRemote = remoteBranches.filter(matches);
|
||||
const matchCount = query
|
||||
? [...(branches || []), ...remoteBranches].filter((b) => b.toLowerCase().includes(query))
|
||||
.length
|
||||
: null;
|
||||
const searchBranches = (text: string) => {
|
||||
setBranchQuery(text);
|
||||
const q = text.trim().toLowerCase();
|
||||
if (!q) return;
|
||||
const hits = [...(branches || []), ...remoteBranches].filter((b) =>
|
||||
b.toLowerCase().includes(q)
|
||||
);
|
||||
if (hits.length === 1) setPicked(hits[0]!);
|
||||
};
|
||||
const merging = !!git.merging;
|
||||
|
||||
const run = async (action: Action, body?: RunBody) => {
|
||||
@@ -549,58 +527,32 @@ export function LaneGitBar({
|
||||
|
||||
{switching && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
type="search"
|
||||
aria-label={t("gitBar.branchSearch")}
|
||||
placeholder={t("gitBar.branchSearch")}
|
||||
value={branchQuery}
|
||||
disabled={!!busy}
|
||||
onChange={(e) => searchBranches(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape" && branchQuery) {
|
||||
e.stopPropagation();
|
||||
setBranchQuery("");
|
||||
} else if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
const first = [...shownBranches, ...shownRemote].find(
|
||||
(b) => b.toLowerCase().includes(query) && b !== git.branch
|
||||
);
|
||||
if (first) setPicked(first);
|
||||
}
|
||||
}}
|
||||
className="w-36 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitBar.switchLabel")}
|
||||
value={picked}
|
||||
disabled={!!busy}
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
||||
>
|
||||
<option value="">
|
||||
{matchCount === null
|
||||
? t("gitBar.switchLabel")
|
||||
: t("gitBar.branchMatches", { count: matchCount })}
|
||||
</option>
|
||||
{shownBranches.map((b) => {
|
||||
const held = heldBy.get(b);
|
||||
return (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{shownRemote.length > 0 && (
|
||||
<optgroup label={t("gitBar.remoteBranches")}>
|
||||
{shownRemote.map((b) => (
|
||||
<option key={`remote:${b}`} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
{/* A combobox once the list is long (Select's search box, #88):
|
||||
type to narrow the local and remote branches. */}
|
||||
<div className="min-w-[8rem] flex-1 font-mono">
|
||||
<Select
|
||||
label={t("gitBar.switchLabel")}
|
||||
searchLabel={t("gitBar.branchSearch")}
|
||||
searchable
|
||||
value={picked}
|
||||
disabled={!!busy}
|
||||
onChange={setPicked}
|
||||
options={[
|
||||
{ value: "", label: t("gitBar.switchLabel") },
|
||||
...(branches || []).map((b) => {
|
||||
const held = heldBy.get(b);
|
||||
const note =
|
||||
b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : "";
|
||||
return { value: b, label: `${b}${note}` };
|
||||
}),
|
||||
...remoteBranches.map((b) => ({
|
||||
value: b,
|
||||
label: b,
|
||||
group: t("gitBar.remoteBranches"),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Btn
|
||||
label={t("gitBar.switchApply")}
|
||||
busy={busy === "switch"}
|
||||
|
||||
@@ -171,21 +171,23 @@ beforeEach(() => {
|
||||
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
|
||||
vi.mocked(h.pick).mockReset();
|
||||
vi.mocked(h.pickPlan).mockReset().mockImplementation(async (_t, op, shas) => ({
|
||||
available: true,
|
||||
op,
|
||||
commits: shas.map((sha) => ({
|
||||
sha,
|
||||
short: sha.slice(0, 7),
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
})),
|
||||
branch: "feat/thing",
|
||||
clean: true,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
}));
|
||||
vi.mocked(h.pickPlan)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_t, op, shas) => ({
|
||||
available: true,
|
||||
op,
|
||||
commits: shas.map((sha) => ({
|
||||
sha,
|
||||
short: sha.slice(0, 7),
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
})),
|
||||
branch: "feat/thing",
|
||||
clean: true,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
}));
|
||||
vi.mocked(h.fileHistory)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
@@ -538,7 +540,8 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTitle("only-on-thing.ts");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
|
||||
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
|
||||
await user.click(screen.getByRole("option", { name: "other" }));
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
||||
@@ -1344,9 +1347,7 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
|
||||
await user.click(await screen.findByTestId("pick-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [
|
||||
"a".repeat(40),
|
||||
])
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", ["a".repeat(40)])
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,13 +13,22 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneGitBar } from "../LaneGitBar";
|
||||
import { api } from "../../../lib/api";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import type { LaneGitFacts } from "../../../lib/types";
|
||||
|
||||
/** Picks `name` in the branch switcher — a Select combobox (#88). */
|
||||
async function pickBranch(user: ReturnType<typeof userEvent.setup>, name: string) {
|
||||
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
|
||||
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
await user.click(
|
||||
within(screen.getByRole("listbox")).getByRole("option", { name: new RegExp(`^${esc}( —|$)`) })
|
||||
);
|
||||
}
|
||||
|
||||
// A merge asks through the themed confirm (its host lives in App): yes.
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -210,7 +219,7 @@ describe("LaneGitBar", () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
// Nothing is sent before the user picks what happens to the changes.
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
@@ -235,7 +244,7 @@ describe("LaneGitBar", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
|
||||
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
|
||||
@@ -258,7 +267,7 @@ describe("LaneGitBar", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
|
||||
await waitFor(() =>
|
||||
@@ -291,7 +300,7 @@ describe("LaneGitBar", () => {
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
|
||||
@@ -319,8 +328,8 @@ describe("LaneGitBar", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await user.selectOptions(select, "development");
|
||||
await screen.findByRole("button", { name: "Switch branch…" });
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -332,12 +341,12 @@ describe("LaneGitBar", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("narrows the branch list as you type, picks a single match and Enter's first one (#88)", async () => {
|
||||
it("opens the branch switcher as a combobox: type to narrow, Enter picks (#88)", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/campaign-pause", "fix/campaign-step", "main"],
|
||||
@@ -348,34 +357,24 @@ describe("LaneGitBar", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
const search = screen.getByLabelText("Search branches…");
|
||||
const options = () =>
|
||||
Array.from(select.querySelectorAll("option"))
|
||||
.map((o) => o.value)
|
||||
.filter(Boolean);
|
||||
|
||||
await user.type(search, "CAMPAIGN");
|
||||
expect(options()).toEqual([
|
||||
const trigger = await screen.findByRole("button", { name: "Switch branch…" });
|
||||
await user.click(trigger);
|
||||
const search = screen.getByRole("combobox", { name: "Search branches…" });
|
||||
expect(search).toHaveFocus();
|
||||
await user.keyboard("CAMPAIGN");
|
||||
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||
"feat/campaign-pause",
|
||||
"fix/campaign-step",
|
||||
"origin/feat/campaign-statistic",
|
||||
]);
|
||||
expect(select.querySelector("option")).toHaveTextContent("3 matching branches");
|
||||
expect(select).toHaveValue("");
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(select).toHaveValue("feat/campaign-pause");
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, "unit_t");
|
||||
// One match: picked at once (the earlier pick stays listed until replaced).
|
||||
expect(select).toHaveValue("origin/feature/thienpt/unit_test");
|
||||
expect(options()).toEqual(["origin/feature/thienpt/unit_test"]);
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(search).toHaveValue("");
|
||||
expect(options()).toHaveLength(6);
|
||||
expect(trigger).toHaveTextContent("feat/campaign-pause");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", {
|
||||
branch: "feat/campaign-pause",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
|
||||
@@ -396,11 +395,12 @@ describe("LaneGitBar", () => {
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
|
||||
expect(
|
||||
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
|
||||
screen.getByText("On the remote — switching creates the local branch")
|
||||
).toBeInTheDocument();
|
||||
await user.selectOptions(select, "origin/feat/new");
|
||||
await user.keyboard("{Escape}");
|
||||
await pickBranch(user, "origin/feat/new");
|
||||
// Not a local branch yet: nothing to delete, no worktree to start on it.
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
|
||||
@@ -436,7 +436,7 @@ describe("LaneGitBar", () => {
|
||||
})
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "New workspace here" }));
|
||||
expect(onNewLane).toHaveBeenCalledWith("development");
|
||||
});
|
||||
@@ -454,18 +454,18 @@ describe("LaneGitBar", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await screen.findByRole("button", { name: "Switch branch…" });
|
||||
const newLane = () => screen.getByRole("button", { name: "New workspace here" });
|
||||
|
||||
// This lane's own branch: its worktree is this lane.
|
||||
await user.selectOptions(select, "feat/thing");
|
||||
await pickBranch(user, "feat/thing");
|
||||
expect(newLane()).toBeDisabled();
|
||||
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
|
||||
await user.selectOptions(select, "development");
|
||||
await pickBranch(user, "development");
|
||||
expect(newLane()).toBeDisabled();
|
||||
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||
|
||||
await user.selectOptions(select, "feat/free");
|
||||
await pickBranch(user, "feat/free");
|
||||
expect(newLane()).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -484,13 +484,15 @@ describe("LaneGitBar", () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
|
||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
|
||||
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
|
||||
expect(await screen.findByRole("button", { name: "Switch branch…" })).toHaveTextContent(
|
||||
"Switch branch…"
|
||||
);
|
||||
});
|
||||
|
||||
it("sends one branch-create for a double Enter", async () => {
|
||||
@@ -517,7 +519,7 @@ describe("LaneGitBar", () => {
|
||||
|
||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await screen.findByRole("button", { name: "Switch branch…" });
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||
// Lane 3's answer lands late: its worktree held `development`.
|
||||
resolveOld({
|
||||
@@ -526,7 +528,7 @@ describe("LaneGitBar", () => {
|
||||
current: "feat/thing",
|
||||
worktrees: [{ path: "/repo/dev", branch: "development" }],
|
||||
});
|
||||
await user.selectOptions(select, "development");
|
||||
await pickBranch(user, "development");
|
||||
const newLane = screen.getByRole("button", { name: "New workspace here" });
|
||||
expect(newLane).toBeEnabled();
|
||||
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||
@@ -613,7 +615,7 @@ describe("merge (issue #45)", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await pickBranch(user, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Merge into current" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
||||
|
||||
@@ -124,6 +124,11 @@ const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v ==
|
||||
const OUTPUT_FETCH_MS = 1000;
|
||||
/** A tool with no known busy line counts as at work this long after its pane last drew. */
|
||||
const OUTPUT_BUSY_MS = 2000;
|
||||
/** How long Stop stays up after every busy signal dropped: the screen's busy
|
||||
* line comes and goes while the CLI redraws, and without a hold the button
|
||||
* flickered between Stop and Send mid-turn. An explicit end (Stop pressed)
|
||||
* is not held. */
|
||||
const BUSY_HOLD_MS = 2500;
|
||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
||||
@@ -140,6 +145,23 @@ const storedSide = () => {
|
||||
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
|
||||
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
||||
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
||||
/** `value`, except that a drop to false shows only once it has lasted
|
||||
* `holdMs`; a change of `release` (Stop pressed) lets it drop at once. */
|
||||
export function useHeldTrue(value: boolean, holdMs: number, release: unknown): boolean {
|
||||
const [held, setHeld] = useState(value);
|
||||
const released = useRef(release);
|
||||
useEffect(() => {
|
||||
if (value) return setHeld(true);
|
||||
if (released.current !== release) {
|
||||
released.current = release;
|
||||
return setHeld(false);
|
||||
}
|
||||
const timer = window.setTimeout(() => setHeld(false), holdMs);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [value, holdMs, release]);
|
||||
return value || held;
|
||||
}
|
||||
|
||||
export function contextWindow(model: string | null): number | null {
|
||||
// A Codex/omp chat reports its own window (ContextMeter's `window`).
|
||||
if (!model?.startsWith("claude-")) return null;
|
||||
@@ -622,13 +644,18 @@ export const PaneChat = memo(function PaneChat({
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
// Stop presses, for the conversation's live line (issue #74).
|
||||
const [stops, setStops] = useState(0);
|
||||
const readWorking = useCallback(
|
||||
() =>
|
||||
// Reads overlap (every throttled transcript change starts one); only the
|
||||
// newest may land, or a slow older answer put back a state already left.
|
||||
const readSeq = useRef(0);
|
||||
const readWorking = useCallback(() => {
|
||||
const seq = ++readSeq.current;
|
||||
return (
|
||||
api.agents
|
||||
.list({ session_id: sessionId, status: "working" })
|
||||
// With session_id the server answers every agent of the session, its
|
||||
// status filter ignored: check the status here.
|
||||
.then((r) => {
|
||||
if (seq !== readSeq.current) return;
|
||||
const main = r.agents.filter((a) => a.type === "main");
|
||||
// A fresh start, resume or `/clear` leaves main "working" at an
|
||||
// empty prompt (SessionStart's flag) until the first prompt.
|
||||
@@ -637,9 +664,9 @@ export const PaneChat = memo(function PaneChat({
|
||||
);
|
||||
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
|
||||
})
|
||||
.catch(() => {}),
|
||||
[sessionId]
|
||||
);
|
||||
.catch(() => {})
|
||||
);
|
||||
}, [sessionId]);
|
||||
useEffect(() => {
|
||||
void readWorking();
|
||||
}, [readWorking]);
|
||||
@@ -714,7 +741,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
}, 700);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [runId, onScreen]);
|
||||
const busy = working || screenBusy;
|
||||
const busy = useHeldTrue(working || screenBusy, BUSY_HOLD_MS, stops);
|
||||
const asking = waiting || menu !== null;
|
||||
// Gone until the pane redraws: no second press on a stale menu.
|
||||
const choose = (index: number) => {
|
||||
|
||||
@@ -215,6 +215,10 @@ describe("PaneChat — Stop", () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
});
|
||||
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2600);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -233,6 +237,10 @@ describe("PaneChat — Stop", () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
});
|
||||
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2600);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -240,6 +248,32 @@ describe("PaneChat — Stop", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not flicker to Send when the busy line drops out for a moment", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(readRunBusy).mockReturnValue(true);
|
||||
try {
|
||||
renderChat();
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
// A redraw: the spinner line is gone for a poll or two.
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
});
|
||||
expect(screen.getByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
vi.mocked(readRunBusy).mockReturnValue(true);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
});
|
||||
expect(screen.getByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("counts a tool with no known busy line as working while its pane draws", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(readRunBusy).mockReturnValue(null);
|
||||
@@ -261,6 +295,10 @@ describe("PaneChat — Stop", () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2800);
|
||||
});
|
||||
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2600);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -131,6 +131,8 @@ export function BackupSettings() {
|
||||
onChange={(v) => setHour(Number(v))}
|
||||
label={t("backup.hour")}
|
||||
disabled={!state || owned?.hour}
|
||||
// 24 fixed hours: type-ahead finds one; a search box would only add noise.
|
||||
searchable={false}
|
||||
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -222,6 +222,8 @@ export function ReportSettings() {
|
||||
value={String(form.value.hour)}
|
||||
onChange={(v) => set({ hour: Number(v) })}
|
||||
label={t("reports.hour")}
|
||||
// 24 fixed hours: type-ahead finds one; a search box would only add noise.
|
||||
searchable={false}
|
||||
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -223,5 +223,9 @@
|
||||
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
|
||||
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"search": "Search…",
|
||||
"noMatch": "No match"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +196,6 @@
|
||||
"gitBar.switch": "Branch",
|
||||
"gitBar.switchApply": "Switch branch",
|
||||
"gitBar.branchSearch": "Search branches…",
|
||||
"gitBar.branchMatches_one": "{{count}} matching branch",
|
||||
"gitBar.branchMatches_other": "{{count}} matching branches",
|
||||
"gitBar.switchCurrent": "current",
|
||||
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
|
||||
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
|
||||
|
||||
@@ -223,5 +223,9 @@
|
||||
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
|
||||
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"search": "Tìm…",
|
||||
"noMatch": "Không có kết quả"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +196,6 @@
|
||||
"gitBar.switch": "Nhánh",
|
||||
"gitBar.switchApply": "Chuyển nhánh",
|
||||
"gitBar.branchSearch": "Tìm nhánh…",
|
||||
"gitBar.branchMatches_one": "{{count}} nhánh khớp",
|
||||
"gitBar.branchMatches_other": "{{count}} nhánh khớp",
|
||||
"gitBar.switchCurrent": "hiện tại",
|
||||
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
|
||||
"gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước",
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.68 (rebuild client to match)
|
||||
UI build v2.8.70 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -531,7 +531,7 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
|
||||
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
||||
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
|
||||
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
|
||||
| **Branch** → **Switch branch** | the **Search branches…** box beside the list narrows it, local and remote, to names containing what you type (one match is picked at once, Enter picks the first, Esc clears — #88); `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
|
||||
| **Branch** → **Switch branch** | the list is a combobox: it opens with a **Search branches…** box focused, typing narrows the local and remote branches to names containing the text, ↑/↓ move, Enter picks, Esc closes (#88 — every `Select` longer than 8 options gets the same search box); `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
|
||||
| **Create & switch** | `git switch -c <name>` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists |
|
||||
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
||||
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.68",
|
||||
"version": "2.8.70",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.68",
|
||||
"version": "2.8.70",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.68",
|
||||
"version": "2.8.70",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @file Regression test: a turn the transcript says has ended lands the main
|
||||
* agent in Waiting even when Claude Code never sends the Stop hook (it skips
|
||||
* it in some turns — only the prompt-suggestion helper's SubagentStop
|
||||
* arrives). Before, the agent stayed `working` until the idle Notification a
|
||||
* minute later and the chat showed Stop on a finished answer. A turn end OLDER
|
||||
* than the last prompt, or one read on new work (UserPromptSubmit), must not.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const http = require("http");
|
||||
|
||||
const STAMP = `turn-end-${Date.now()}-${process.pid}`;
|
||||
const TMP = path.join(os.tmpdir(), STAMP);
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { stmts } = require("../db");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
function post(body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL("/api/hooks/event", BASE);
|
||||
const payload = JSON.stringify(body);
|
||||
const r = http.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) },
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", () => resolve(res.statusCode));
|
||||
}
|
||||
);
|
||||
r.on("error", reject);
|
||||
r.write(payload);
|
||||
r.end();
|
||||
});
|
||||
}
|
||||
|
||||
const line = (o) => JSON.stringify(o) + "\n";
|
||||
const user = (text) => ({ type: "user", message: { role: "user", content: text } });
|
||||
const reply = (id) => ({
|
||||
type: "assistant",
|
||||
message: { id, model: "claude-sonnet-5", content: [{ type: "text", text: "ok" }], usage: { input_tokens: 1, output_tokens: 1 } },
|
||||
});
|
||||
const turnEnd = (ts) => ({ type: "system", subtype: "turn_duration", durationMs: 900, timestamp: ts });
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(TMP, { recursive: true });
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("a finished turn without a Stop hook", () => {
|
||||
it("lands in Waiting on the next hook that reads the turn's end", async () => {
|
||||
const cwd = path.join(TMP, "work");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
const sid = "12121212-3434-5656-7878-909090909090";
|
||||
const tpath = path.join(TMP, `${sid}.jsonl`);
|
||||
const event = (extra = {}) => ({ session_id: sid, cwd, transcript_path: tpath, ...extra });
|
||||
const main = () => stmts.getAgent.get(`${sid}-main`);
|
||||
|
||||
fs.writeFileSync(tpath, line(user("hello")));
|
||||
assert.equal(await post({ hook_type: "UserPromptSubmit", data: event({ prompt: "hello" }) }), 200);
|
||||
assert.equal(main().status, "working");
|
||||
|
||||
// The turn ends; Claude Code writes turn_duration but sends no Stop —
|
||||
// only a helper agent's SubagentStop arrives.
|
||||
fs.appendFileSync(tpath, line(reply("m1")) + line(turnEnd(new Date().toISOString())));
|
||||
assert.equal(
|
||||
await post({ hook_type: "SubagentStop", data: event({ agent_id: "a1", agent_type: "" }) }),
|
||||
200
|
||||
);
|
||||
assert.equal(main().status, "waiting");
|
||||
assert.equal(stmts.getSession.get(sid).awaiting_reason, "stop");
|
||||
|
||||
// A new prompt: that turn's old end must not end the new one.
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
fs.appendFileSync(tpath, line(user("again")));
|
||||
assert.equal(await post({ hook_type: "UserPromptSubmit", data: event({ prompt: "again" }) }), 200);
|
||||
assert.equal(main().status, "working");
|
||||
assert.equal(
|
||||
await post({ hook_type: "SubagentStop", data: event({ agent_id: "a2", agent_type: "" }) }),
|
||||
200
|
||||
);
|
||||
assert.equal(main().status, "working", "no turn end newer than the prompt yet");
|
||||
});
|
||||
});
|
||||
@@ -294,6 +294,10 @@ const apiErrorSummariesStmt = db.prepare(
|
||||
const turnDurationStampsStmt = db.prepare(
|
||||
"SELECT created_at FROM events WHERE session_id = ? AND event_type = 'TurnDuration'"
|
||||
);
|
||||
// When the session's current turn began: the newest UserPromptSubmit.
|
||||
const lastPromptAtStmt = db.prepare(
|
||||
"SELECT MAX(created_at) AS at FROM events WHERE session_id = ? AND event_type = 'UserPromptSubmit'"
|
||||
);
|
||||
|
||||
// What this process already handled of each session's transcript parse.
|
||||
// `transcriptCache.extract` returns the SAME result object while the file is
|
||||
@@ -1202,6 +1206,7 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
||||
? null
|
||||
: tailAfter(result.turnDurations, mark?.lastTurn, (a, b) => a === b);
|
||||
if (freshTurns && freshTurns.length) {
|
||||
endTurnFromTranscript(sessionId, mainAgentId, hookType, freshTurns);
|
||||
// `turnDurations` is every turn in the transcript (up to 1000), on
|
||||
// every hook: dedupe against ONE read of the recorded timestamps (a
|
||||
// covering-index scan) instead of a query per turn — 23 ms per hook on
|
||||
@@ -1327,6 +1332,33 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
||||
* that transaction COMMITTED — in order, in the same tick. A frame sent from
|
||||
* inside it described a row a later throw could still roll back.
|
||||
*/
|
||||
/**
|
||||
* A turn the transcript says has ended is over, Stop hook or not. Claude Code
|
||||
* writes a `turn_duration` entry when a turn finishes, but in roughly one turn
|
||||
* in fifteen it never sends the Stop hook (seen on 2.1.31x: only the
|
||||
* prompt-suggestion helper's SubagentStop arrives), and the main agent then
|
||||
* stayed `working` until the idle Notification a minute later — the chat
|
||||
* showed Stop on a finished answer. So a turn end NEWER than the session's
|
||||
* last prompt, read while the main agent is still working with no tool open,
|
||||
* lands it in Waiting exactly like Stop does. A UserPromptSubmit/PreToolUse is
|
||||
* new work and never ends a turn: its own read may still carry the previous
|
||||
* turn's entry.
|
||||
*/
|
||||
function endTurnFromTranscript(sessionId, mainAgentId, hookType, turns) {
|
||||
if (!mainAgentId || hookType === "UserPromptSubmit" || hookType === "PreToolUse") return;
|
||||
const main = stmts.getAgent.get(mainAgentId);
|
||||
if (!main || main.status !== "working" || main.current_tool) return;
|
||||
const prompt = lastPromptAtStmt.get(sessionId)?.at;
|
||||
const end = turns.reduce((m, td) => (td.timestamp && td.timestamp > m ? td.timestamp : m), "");
|
||||
if (!end || (prompt && end <= prompt)) return;
|
||||
const now = new Date().toISOString();
|
||||
stmts.updateAgent.run(null, "waiting", null, null, null, null, mainAgentId);
|
||||
stmts.setSessionAwaitingInput.run(now, "stop", sessionId);
|
||||
stmts.setAgentAwaitingInput.run(now, "stop", mainAgentId);
|
||||
broadcast("session_updated", stmts.getSession.get(sessionId));
|
||||
broadcast("agent_updated", stmts.getAgent.get(mainAgentId));
|
||||
}
|
||||
|
||||
function processEvent(hookType, data, tool, peer = null) {
|
||||
const outer = pendingMarks;
|
||||
pendingMarks = [];
|
||||
|
||||
Reference in New Issue
Block a user