Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81b3cbda65 | |||
| 0a99eed369 | |||
| 1ec9f2e592 | |||
| a401a318c5 | |||
| 5caad0df2d | |||
| 01e66ba122 | |||
| 923c1635b4 | |||
| c37bc5205b |
@@ -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.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
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.56 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.52",
|
||||
"version": "2.8.56",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.52",
|
||||
"version": "2.8.56",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.52",
|
||||
"version": "2.8.56",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user