Compare commits

...

11 Commits

Author SHA1 Message Date
nntrivi2001 12d53afaeb fix(changes): long branch name truncates; armed Discard matches the row icons
The branch name was shrink-0, so a long one ran over the head sha and the
action icons. It now truncates after the commit subject (full name in the
tooltip). The armed per-file Discard is the trash icon plus a small red
word on a faint tint, sized like its neighbours, instead of a bordered chip.
2026-11-02 14:44:23 +07:00
nntrivi2001 4263b3eb26 chore: v2.8.57 — chat column widens to 64rem 2026-11-02 14:42:01 +07:00
nntrivi2001 d58f13e1ce fix(chat): conversation column widens to 64rem instead of 48rem
A wide pane left the chat squeezed into a 768px column with empty space
either side, so a wide table was cut off. The message list, the prompt
box and the session replay now share max-w-5xl.
2026-11-02 14:41:56 +07:00
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
14 changed files with 122 additions and 34 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# 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.57 (2026-11-02) — the chat column widens to 64rem (was 48rem)._ 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
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -865,7 +865,7 @@ function MessageListView({
className={
compact
? "space-y-3 py-1 pr-2"
: "mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-6"
: "mx-auto flex w-full max-w-5xl flex-col gap-3 px-4 py-6"
}
>
{items.map((item) => {
@@ -489,8 +489,9 @@ function FileActions({
title={t("changes.discardConfirm")}
disabled={busy}
onClick={() => onAct("discard", f.path, true)}
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
className="flex shrink-0 items-center gap-1 rounded bg-status-danger/10 px-1 py-0.5 text-[11px] leading-3 text-status-danger hover:bg-status-danger/20 disabled:opacity-40"
>
<Trash2 className="h-3 w-3" />
{t("changes.discardShortConfirm")}
</button>
) : (
@@ -373,12 +373,17 @@ export function LaneGitHeader({
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
title={`${git.head} ${git.subject}`}
>
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
<GitBranch className="h-3 w-3 text-fg-muted" />
{git.branch}
{/* A long branch name truncates too (after the subject has gone),
or it overflowed onto the head and the action icons. */}
<span
className="flex min-w-[5rem] items-center gap-1 self-center font-semibold text-fg-primary"
title={git.branch}
>
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="truncate">{git.branch}</span>
</span>
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
<span className="min-w-0 shrink-[999] truncate text-fg-muted">{git.subject}</span>
</div>
</>
);
@@ -145,7 +145,7 @@ export function SessionReplay({
{livePane ? (
<div
data-testid="session-replay-live"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.live")}</span>
<button
@@ -160,7 +160,7 @@ export function SessionReplay({
) : session && !resumable ? (
<div
data-testid="session-replay-no-resume"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
<button
@@ -172,7 +172,7 @@ export function SessionReplay({
</button>
</div>
) : (
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
<div className="mx-auto w-full max-w-5xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
+46 -9
View File
@@ -13,7 +13,8 @@
* 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
* 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>
*/
@@ -77,7 +78,36 @@ function loadLists(cwd: string): Promise<Lists> {
}
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 =
"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 navigate = useNavigate();
const [open, setOpen] = useState(false);
const [down, setDown] = useState(false);
const [sub, setSub] = useState<Sub | null>(null);
const [context, setContext] = useState<Attachment[] | null>(null);
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);
return (
@@ -296,13 +329,17 @@ export function ChatAddMenu({
aria-label={t("chat.add")}
title={t("chat.add")}
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"
>
<Plus className="h-4 w-4" />
</button>
{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
icon={<Paperclip className="h-4 w-4" />}
label={t("chat.upload")}
@@ -376,13 +413,13 @@ export function ChatAddMenu({
)}
{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))} />
</div>
)}
{(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">
{(sub === "context" ? context : recent)?.length ? (
(sub === "context" ? context! : recent).map((it) => (
@@ -405,7 +442,7 @@ export function ChatAddMenu({
)}
{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="my-1 border-t border-border" />
<Row
@@ -417,7 +454,7 @@ export function ChatAddMenu({
)}
{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">
{lists && lists.plugins.length === 0 && (
<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")}
/>
{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">
{entries(
shownPlugin.items,
+13 -5
View File
@@ -16,8 +16,9 @@
* 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
* (`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
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
* or an installed, enabled AI tool started bare on its default model and
* 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
* (`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)
@@ -72,6 +73,7 @@ import {
type GroupsUpdate,
} from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
import {
type PaneGroups,
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
* different tool is named, as the home does: the workspace's own keeps
* starting even once disabled. */
/** The start body of a bare tool on its default model, no prompt, in the
* account's Session defaults (Claude's permission mode, Codex's sandbox,
* 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) {
const model = aiTools.byId(id)?.defaultModel;
const codex = id === "codex" ? readCodexDefaults() : null;
return {
prompt: "",
...(id !== lane.tool ? { tool: id } : {}),
...(model ? { model } : {}),
...(id === "claude" ? { permissionMode: readDefaultMode() } : {}),
...(codex ? { sandbox: codex.sandbox, approval: codex.approval } : {}),
...(codex?.bypass && aiTools.byId(id)?.bypass ? { bypass: true } : {}),
};
}
+5 -2
View File
@@ -689,8 +689,11 @@ export const PaneChat = memo(function PaneChat({
// another dashboard on the same database, or the socket dropped) would
// otherwise leave Stop up after the turn ended.
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) =>
msg.type === "agent_updated" &&
(msg.type === "agent_updated" || msg.type === "transcript_changed") &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
@@ -855,7 +858,7 @@ export const PaneChat = memo(function PaneChat({
)}
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-3xl">
<div className="mx-auto w-full max-w-5xl">
{menu && (
<PaneMenuCard
// A new question starts with its field closed.
@@ -432,6 +432,14 @@ describe("readRunBusy", () => {
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
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.
rows = ["Esc to cancel · Tab to amend"];
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
* 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
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
* tool missing here (omp, Cursor) has no line we know. */
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it.
* 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>> = {
claude: /\besc to interrupt\b/i,
claude: /\besc to interrupt\b|…\s*\(\s*\d+[smh]\b/i,
codex: /\besc to interrupt\b/i,
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
};
+22 -1
View File
@@ -1053,7 +1053,28 @@ describe("split panes", () => {
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: "" });
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 () => {
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.52 (rebuild client to match)
UI build v2.8.57 (rebuild client to match)
</span>
</p>
</div>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.52",
"version": "2.8.57",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.52",
"version": "2.8.57",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.52",
"version": "2.8.57",
"bin": {
"agenthub": "bin/agenthub.js"
},