Compare commits

...

8 Commits

Author SHA1 Message Date
nntrivi2001 81b3cbda65 chore: v2.8.56 — new tab / split pane starts in the Session defaults 2026-11-02 13:34:36 +07:00
nntrivi2001 0a99eed369 fix(workspace): a new tab or split pane starts in the account's Session defaults
newTabBody sent only prompt/tool/model, so a Claude tab opened from the tab
bar + or a split menu always started in auto mode even with Bypass
permissions set as the default; a Codex one ignored its sandbox, approval
and YOLO defaults. It now sends the same defaults the workspace home's
prompt box starts from.
2026-11-02 13:34:10 +07:00
nntrivi2001 1ec9f2e592 chore: v2.8.55 — chat + menu no longer covered by the controls under the prompt box 2026-11-02 13:30:33 +07:00
nntrivi2001 a401a318c5 fix(chat): + menu paints above the controls row under the prompt box
Opened downward, the panel had no z-index, so the AI Proxy / Permission /
Local selects (positioned, later in the DOM) drew over its first rows.
2026-11-02 13:29:08 +07:00
nntrivi2001 5caad0df2d chore: v2.8.54 — chat Stop shows while Claude thinks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:29:05 +07:00
nntrivi2001 01e66ba122 fix(chat): Stop shows while Claude thinks, not only while its footer says "esc to interrupt"
The chat's live line said "Thinking… 21s" while the box offered Send, so the
turn could not be stopped. Stop shows only when a hook marked main "working"
or the pane's busy line matched, and Claude Code 2.1.3xx writes
"esc to interrupt" only in its footer, which a narrow pane, a tip or a notice
replaces. Claude's busy line now also matches its spinner (verb… (41s · …)),
which the line a finished turn leaves ("Cooked for 1m 2s") does not; and the
chat re-reads "working" on transcript_changed, so a hook that reached another
dashboard on the same database no longer leaves Stop hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:29:05 +07:00
nntrivi2001 923c1635b4 chore: v2.8.53 — chat + menu opens downward when there is no room above
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:25:38 +07:00
nntrivi2001 c37bc5205b fix(chat): + menu opens downward when the prompt box sits too near the top
On the workspace home the box is high on the screen, so the menu and its
submenus (Project files) opened upward and were clipped off the top.
Measure the room above/below (viewport narrowed by clipping ancestors) at
open and drop the menu below the button, submenus top-aligned, when it fits better.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:25:05 +07:00
10 changed files with 106 additions and 24 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: v2.8.52 (2026-11-02) — git: shelves, branches & tags, history graph + multi cherry-pick, merge editor, rebase/reset/force-with-lease (47 actions)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. _Last synced: v2.8.56 (2026-11-02) — a new tab or split pane starts in the account's Session defaults (bypass included)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
+46 -9
View File
@@ -13,7 +13,8 @@
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same * puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists * text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`), * are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
* and Manage opens the Claude config page on that tab. * and Manage opens the Claude config page on that tab. The menu opens
* downward when the box sits too near the top to fit it above.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -77,7 +78,36 @@ function loadLists(cwd: string): Promise<Lists> {
} }
const PANEL = const PANEL =
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in"; "absolute z-40 w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
/** Room the menu and its tallest submenu (Project files: search + list) need. */
const MENU_ROOM = 380;
/** The visible band the button sits in: the viewport, narrowed by any
* scrolling/clipping ancestor (the page body scrolls inside the layout). */
function clipBand(el: HTMLElement): { top: number; bottom: number } {
let top = 0;
let bottom = window.innerHeight;
for (let p = el.parentElement; p; p = p.parentElement) {
const o = getComputedStyle(p);
if (/(auto|scroll|hidden|clip)/.test(o.overflowY + o.overflow)) {
const r = p.getBoundingClientRect();
top = Math.max(top, r.top);
bottom = Math.min(bottom, r.bottom);
}
}
return { top, bottom };
}
/** Open downward when there is not room above but more below. */
function opensDown(el: HTMLElement | null): boolean {
if (!el) return false;
const r = el.getBoundingClientRect();
const band = clipBand(el);
const above = r.top - band.top;
const below = band.bottom - r.bottom;
return above < MENU_ROOM && below > above;
}
const ROW = const ROW =
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"; "flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
@@ -195,6 +225,7 @@ export function ChatAddMenu({
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const navigate = useNavigate(); const navigate = useNavigate();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [down, setDown] = useState(false);
const [sub, setSub] = useState<Sub | null>(null); const [sub, setSub] = useState<Sub | null>(null);
const [context, setContext] = useState<Attachment[] | null>(null); const [context, setContext] = useState<Attachment[] | null>(null);
const [recent, setRecent] = useState<Attachment[]>([]); const [recent, setRecent] = useState<Attachment[]>([]);
@@ -286,6 +317,8 @@ export function ChatAddMenu({
/> />
)) ))
); );
// A submenu lines up with the menu's edge nearest the button.
const edge = down ? "top-0" : "bottom-0";
const shownPlugin = lists?.plugins.find((p) => p.name === plugin); const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
return ( return (
@@ -296,13 +329,17 @@ export function ChatAddMenu({
aria-label={t("chat.add")} aria-label={t("chat.add")}
title={t("chat.add")} title={t("chat.add")}
aria-expanded={open} aria-expanded={open}
onClick={() => (open ? close() : setOpen(true))} onClick={() => {
if (open) return close();
setDown(opensDown(ref.current));
setOpen(true);
}}
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary" className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</button> </button>
{open && ( {open && (
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}> <div role="menu" data-testid="chat-add-menu" className={`${PANEL} left-0 ${down ? "top-full mt-2" : "bottom-full mb-2"}`}>
<Row <Row
icon={<Paperclip className="h-4 w-4" />} icon={<Paperclip className="h-4 w-4" />}
label={t("chat.upload")} label={t("chat.upload")}
@@ -376,13 +413,13 @@ export function ChatAddMenu({
)} )}
{sub === "project" && ( {sub === "project" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}> <div role="menu" className={`${PANEL} ${edge} left-full ml-1 w-72 pt-2`}>
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} /> <ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
</div> </div>
)} )}
{(sub === "context" || sub === "recent") && ( {(sub === "context" || sub === "recent") && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}> <div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto"> <div className="max-h-72 overflow-y-auto">
{(sub === "context" ? context : recent)?.length ? ( {(sub === "context" ? context : recent)?.length ? (
(sub === "context" ? context! : recent).map((it) => ( (sub === "context" ? context! : recent).map((it) => (
@@ -405,7 +442,7 @@ export function ChatAddMenu({
)} )}
{sub === "skills" && ( {sub === "skills" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}> <div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div> <div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
<div className="my-1 border-t border-border" /> <div className="my-1 border-t border-border" />
<Row <Row
@@ -417,7 +454,7 @@ export function ChatAddMenu({
)} )}
{sub === "plugins" && ( {sub === "plugins" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}> <div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto"> <div className="max-h-72 overflow-y-auto">
{lists && lists.plugins.length === 0 && ( {lists && lists.plugins.length === 0 && (
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p> <p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
@@ -443,7 +480,7 @@ export function ChatAddMenu({
onClick={() => manage("plugins")} onClick={() => manage("plugins")}
/> />
{shownPlugin && ( {shownPlugin && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}> <div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto"> <div className="max-h-72 overflow-y-auto">
{entries( {entries(
shownPlugin.items, shownPlugin.items,
+13 -5
View File
@@ -16,8 +16,9 @@
* keys, Home and End move between tabs (one tab stop, APG). Errors are * keys, Home and End move between tabs (one tab stop, APG). Errors are
* toasts. The `+` menu opens a new tab in the free slot the server picks * toasts. The `+` menu opens a new tab in the free slot the server picks
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`) * (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
* or an installed, enabled AI tool started bare on its default model, no * or an installed, enabled AI tool started bare on its default model and
* prompt (a prompt, proxy or worktree is still the workspace home's prompt * the account's Session defaults (permission mode, Codex sandbox/approval/
* bypass), no prompt (a prompt, proxy or worktree is still the workspace home's prompt
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit * box) — plus a link to Settings → Agents. Ten sessions is the server's limit
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the * (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus) * workspace's home) at its left end, `actions` (the single pane's split menus)
@@ -72,6 +73,7 @@ import {
type GroupsUpdate, type GroupsUpdate,
} from "./PaneGrid"; } from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage"; import { insertionIndex } from "../../lib/laneOrderStorage";
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
import { import {
type PaneGroups, type PaneGroups,
MAX_PANES, MAX_PANES,
@@ -140,15 +142,21 @@ export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
]; ];
} }
/** The start body of a bare tool on its default model, no prompt. Only a /** The start body of a bare tool on its default model, no prompt, in the
* different tool is named, as the home does: the workspace's own keeps * account's Session defaults (Claude's permission mode, Codex's sandbox,
* starting even once disabled. */ * approval and bypass) — the same ones the workspace home's prompt box starts
* from. Only a different tool is named, as the home does: the workspace's own
* keeps starting even once disabled. */
export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) { export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
const model = aiTools.byId(id)?.defaultModel; const model = aiTools.byId(id)?.defaultModel;
const codex = id === "codex" ? readCodexDefaults() : null;
return { return {
prompt: "", prompt: "",
...(id !== lane.tool ? { tool: id } : {}), ...(id !== lane.tool ? { tool: id } : {}),
...(model ? { model } : {}), ...(model ? { model } : {}),
...(id === "claude" ? { permissionMode: readDefaultMode() } : {}),
...(codex ? { sandbox: codex.sandbox, approval: codex.approval } : {}),
...(codex?.bypass && aiTools.byId(id)?.bypass ? { bypass: true } : {}),
}; };
} }
+4 -1
View File
@@ -689,8 +689,11 @@ export const PaneChat = memo(function PaneChat({
// another dashboard on the same database, or the socket dropped) would // another dashboard on the same database, or the socket dropped) would
// otherwise leave Stop up after the turn ended. // otherwise leave Stop up after the turn ended.
intervalMs: working || waiting ? 5000 : undefined, intervalMs: working || waiting ? 5000 : undefined,
// The transcript growing re-reads it too: a hook that reached another
// dashboard on the same database sends this one no agent_updated, and
// without a poll Stop never showed for a turn the chat calls Thinking….
match: (msg) => match: (msg) =>
msg.type === "agent_updated" && (msg.type === "agent_updated" || msg.type === "transcript_changed") &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId, (msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
}); });
@@ -432,6 +432,14 @@ describe("readRunBusy", () => {
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"]; let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows); const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true); expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
// Claude's spinner with the footer's hint gone (a narrow pane, a tip).
rows = ["✶ Skedaddling… (41s · ↓ 2.8k tokens · thought for 2s)", " ⏵⏵ auto mode on"];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
rows = ["✻ Thinking… (1m 3s)"];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
// The line a finished turn leaves is not.
rows = ["✻ Cooked for 1m 2s", "❯ "];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
// Claude's permission footer is not a turn at work. // Claude's permission footer is not a turn at work.
rows = ["Esc to cancel · Tab to amend"]; rows = ["Esc to cancel · Tab to amend"];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false); expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
+8 -3
View File
@@ -368,10 +368,15 @@ function typeRow(options: PaneMenuOption[]): number {
/** The status line a tool's TUI shows only while a turn runs, read off its /** The status line a tool's TUI shows only while a turn runs, read off its
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's * binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so * `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A * Claude's permission footer (`Esc to cancel · Tab to amend`) is not it.
* tool missing here (omp, Cursor) has no line we know. */ * Claude ≥ 2.1.3xx keeps `esc to interrupt` only in its footer, which a
* narrow pane, a tip or a notice replaces, so its spinner line counts too:
* a verb, an ellipsis and the turn's time in parentheses
* (`✶ Skedaddling… (41s · ↓ 2.8k tokens)`) — the line a finished turn leaves
* (`✻ Cooked for 1m 2s`) has neither. A tool missing here (omp, Cursor) has
* no line we know. */
export const BUSY_LINE: Readonly<Record<string, RegExp>> = { export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
claude: /\besc to interrupt\b/i, claude: /\besc to interrupt\b|…\s*\(\s*\d+[smh]\b/i,
codex: /\besc to interrupt\b/i, codex: /\besc to interrupt\b/i,
antigravity: /…\s*\([^)]*\besc to cancel\)/i, antigravity: /…\s*\([^)]*\besc to cancel\)/i,
}; };
+22 -1
View File
@@ -1053,7 +1053,28 @@ describe("split panes", () => {
const user = userEvent.setup(); const user = userEvent.setup();
await user.click(screen.getByTestId("pane-split-right-1")); await user.click(screen.getByTestId("pane-split-right-1"));
await user.click(screen.getByTestId("pane-split-new-right-1-claude")); await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", prompt: "" }); expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: "auto",
prompt: "",
permissionMode: "auto",
});
});
it("a new AI pane starts in the account's Session defaults (bypass included)", async () => {
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
lanesToReturn = lanesToReturn.map((l) => ({ ...l, tool: "claude" }));
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
const user = userEvent.setup();
await user.click(screen.getByTestId("pane-split-right-1"));
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: "auto",
prompt: "",
permissionMode: "bypassPermissions",
});
localStorage.removeItem("agenthub.permissionMode");
}); });
it("with one pane, the split menus sit beside Tools and split it", async () => { it("with one pane, the split menus sit beside Tools and split it", async () => {
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.8.52 (rebuild client to match) UI build v2.8.56 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.52", "version": "2.8.56",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.52", "version": "2.8.56",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.52", "version": "2.8.56",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },