Compare commits

..

6 Commits

Author SHA1 Message Date
nntrivi2001 3daf7ed4cb chore: v2.8.70 — select combobox, chat turn end without Stop, no Stop flicker 2026-11-03 14:09:39 +07:00
nntrivi2001 f9869ada6d fix(chat): a finished turn ends without a Stop hook; Stop no longer flickers
Claude Code skips the Stop hook in roughly one turn in fifteen (only the
prompt-suggestion helper's SubagentStop arrives), so the main agent stayed
"working" until the idle Notification a minute later and the chat kept
showing Stop on a finished answer. The transcript's turn_duration entry is
now an end of turn too: one newer than the session's last prompt, read
while main still works with no tool open, lands it in Waiting like Stop.

The chat's Stop button now drops to Send only after every busy signal has
been quiet for 2.5 s (Stop pressed still drops at once), so a spinner line
that comes and goes while the CLI redraws no longer flips it back and forth,
and only the newest "working" read is applied.
2026-11-03 14:02:33 +07:00
nntrivi2001 0fc436dc0c chore(select): no search box for the hour pickers and the webhook type
24 fixed hours (reports, backups) are found by type-ahead and the webhook
providers are a short fixed list; a search box there is only noise.
Branches, models, AI proxies and webhook field options keep theirs.
2026-11-03 13:33:12 +07:00
nntrivi2001 cd25791160 feat(select): long lists become a combobox; the branch switcher uses it (#88)
Select opens with a focused search box when it has more than 8 options
(or searchable): typing narrows by label/value, ↑/↓ move, Enter picks,
Esc closes; role=combobox drives the listbox via aria-activedescendant,
and options may carry a group heading. The git bar's native branch
select (and the separate search box added for #88) is replaced by it,
remote branches under their own heading.
2026-11-03 13:25:16 +07:00
nntrivi2001 2a83551eee chore: v2.8.69 — pet window width 2026-11-03 13:05:49 +07:00
nntrivi2001 d0889d5250 fix(pet): the pet window no longer scrolls sideways
The status chips' row needs ~340 px; the window was 320 and its body's
overflow-y:auto made x scrollable too, so a horizontal scrollbar showed
and Clear alerts was cut. 22rem wide (capped to the viewport), x hidden.
2026-11-03 13:05:49 +07:00
21 changed files with 510 additions and 209 deletions
+4 -2
View File
@@ -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>
);
}
+129 -52
View File
@@ -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();
});
});
+27 -75
View File
@@ -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" })
+33 -6
View File
@@ -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>
+4
View File
@@ -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"
}
}
-2
View File
@@ -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",
+4
View File
@@ -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ả"
}
}
-2
View File
@@ -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
View File
@@ -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 |
+2 -2
View File
@@ -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
View File
@@ -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");
});
});
+32
View File
@@ -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 = [];