feat: right-click menus everywhere, workspace/session cost, long-term DB safety
Right-click menus: one portal shell (components/ContextMenu.tsx) for every item with actions - sidebar/Recents/archived session rows (SessionMenu), console tabs, pane headers, the Changes panel's files/tree/commits, the workspace home, sessions outside a workspace (SessionRowMenu: Sessions table, Dashboard, Activity, Workflow runs), prompt images, every Settings list and every Claude Code/Codex config list (FileItemMenu). Buttons stay; a delete keeps its button's confirm; the shell stops portal clicks from reaching the card that opened it. Status bar: Workspace $ and Session $ on the Workspace page, from ?lane= on GET /api/pricing/cost and the focused/replayed session's cost. Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB, hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created (replacing the redundant idx_events_session), watchdog reads sessions.transcript_path, transcript parsed before the hook's write transaction, GET /api/sessions/:id?events=0 for the web page (reloads only on its own session, debounced), streamed export, transactional clear-data and chunked cleanup, subagent import reads tool_use_ids once, runtime logs rotated past 5 MB.
This commit is contained in:
@@ -11,7 +11,8 @@
|
||||
* bar is the console's only header: Home (the workspace's home) at its left
|
||||
* end, `actions` (the workspace's Tools menu) at its right end. A click shows a tab in the focused pane, Ctrl/Cmd+click
|
||||
* (on a tab or a `+` item) opens it in a split to the right, and a tab drags
|
||||
* onto the panes (`PaneGrid.tsx`); a tab another pane shows is marked.
|
||||
* onto the panes (`PaneGrid.tsx`); a tab another pane shows is marked. A
|
||||
* right-click on a tab opens the same actions as a menu (`ContextMenu`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -26,6 +27,7 @@ import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { freeSlot } from "../lanes/WorkspaceHome";
|
||||
import { RunIcon, startPaneDrag } from "./PaneGrid";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
|
||||
export function ConsoleTabs({
|
||||
lane,
|
||||
@@ -59,6 +61,7 @@ export function ConsoleTabs({
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const tabCtx = useContextMenu<RunHandle>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
@@ -106,6 +109,13 @@ export function ConsoleTabs({
|
||||
}
|
||||
};
|
||||
|
||||
// A tab-menu item: close the menu, then act on the tab it was opened on.
|
||||
const pickTab = (fn: (r: RunHandle) => unknown) => () => {
|
||||
const r = tabCtx.menu?.item;
|
||||
tabCtx.close();
|
||||
if (r) fn(r);
|
||||
};
|
||||
|
||||
const item =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
@@ -132,6 +142,7 @@ export function ConsoleTabs({
|
||||
key={r.id}
|
||||
draggable
|
||||
onDragStart={(e) => startPaneDrag(e, r.slot ?? 0)}
|
||||
onContextMenu={(e) => tabCtx.open(e, r)}
|
||||
data-shown={shown ? "true" : undefined}
|
||||
className={`group relative flex min-w-0 max-w-[15rem] items-center rounded-t-md ${
|
||||
active
|
||||
@@ -252,6 +263,35 @@ export function ConsoleTabs({
|
||||
</span>
|
||||
)}
|
||||
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
|
||||
{tabCtx.menu && (
|
||||
<ContextMenu
|
||||
x={tabCtx.menu.x}
|
||||
y={tabCtx.menu.y}
|
||||
onClose={tabCtx.close}
|
||||
testId={`console-tab-menu-${tabCtx.menu.item.slot ?? 0}`}
|
||||
>
|
||||
<MenuItem onSelect={pickTab((r) => onOpen(r.id))}>{t("sessionMenu.open")}</MenuItem>
|
||||
<MenuItem onSelect={pickTab((r) => onOpen(r.id, true))}>
|
||||
{t("sessionMenu.openSplit")}
|
||||
</MenuItem>
|
||||
{tabCtx.menu.item.sessionId && (
|
||||
<>
|
||||
<MenuItem onSelect={pickTab((r) => navigate(`/sessions/${r.sessionId}`))}>
|
||||
{t("sessionMenu.details")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={pickTab((r) => void navigator.clipboard.writeText(r.sessionId ?? ""))}
|
||||
>
|
||||
{t("sessionMenu.copyId")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pickTab((r) => void close(r.id))}>
|
||||
{t("tabs.close")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* header: drag it to move the pane (drop on another pane's edge to split
|
||||
* there, on its middle to swap), pick another live session for it, split it
|
||||
* right or down, or close the pane (its session keeps running as a tab).
|
||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). Dividers
|
||||
* resize by pointer or by arrow keys.
|
||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
|
||||
* right-click on a header offers the swap, a split with a new terminal and
|
||||
* the close as one menu. Dividers resize by pointer or by arrow keys.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import {
|
||||
EMPTY_LAYOUT,
|
||||
type Edge,
|
||||
@@ -171,6 +173,90 @@ function PaneMenu({
|
||||
const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
/** A pane's split-right / split-down menus: move a hidden run beside
|
||||
* `slot`, or start a new terminal there. In a pane header when the view is
|
||||
* split, beside Tools in the tab bar while one pane is shown. */
|
||||
export function SplitMenus({
|
||||
slot,
|
||||
shown,
|
||||
runs,
|
||||
label,
|
||||
onChange,
|
||||
canStart,
|
||||
onNewSplit,
|
||||
}: {
|
||||
slot: number;
|
||||
/** The slots on screen. */
|
||||
shown: number[];
|
||||
runs: RunHandle[];
|
||||
label: (r: RunHandle) => string;
|
||||
onChange: LayoutUpdate;
|
||||
canStart: boolean;
|
||||
onNewSplit: (target: number, edge: Edge) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const full = shown.length >= MAX_PANES;
|
||||
return (
|
||||
<>
|
||||
{(["right", "bottom"] as const).map((edge) => (
|
||||
<PaneMenu
|
||||
key={edge}
|
||||
testId={`pane-split-${edge}-${slot}`}
|
||||
label={full ? t("panes.full") : t(`panes.split.${edge}`)}
|
||||
disabled={full}
|
||||
icon={
|
||||
edge === "right" ? (
|
||||
<Columns2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Rows2 className="h-3.5 w-3.5" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{(close) => (
|
||||
<>
|
||||
{runs
|
||||
.filter((x) => !shown.includes(x.slot ?? 0))
|
||||
.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={ITEM}
|
||||
onClick={() => {
|
||||
close();
|
||||
onChange((l) => moveInLayout(l, x.slot ?? 0, slot, edge));
|
||||
}}
|
||||
>
|
||||
<RunIcon run={x} label={label(x)} />
|
||||
<span className="min-w-0 truncate">{label(x)}</span>
|
||||
</button>
|
||||
))}
|
||||
{runs.some((x) => !shown.includes(x.slot ?? 0)) && (
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={`pane-split-new-${edge}-${slot}`}
|
||||
disabled={!canStart}
|
||||
title={canStart ? undefined : t("tabs.full")}
|
||||
className={`${ITEM} disabled:opacity-40 disabled:hover:bg-transparent`}
|
||||
onClick={() => {
|
||||
close();
|
||||
onNewSplit(slot, edge);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("panes.newTerminal")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</PaneMenu>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneGrid({
|
||||
layout,
|
||||
mounted,
|
||||
@@ -203,6 +289,13 @@ export function PaneGrid({
|
||||
const multi = panes.length > 1;
|
||||
const full = shown.length >= MAX_PANES;
|
||||
const runOf = (slot: number) => runs.find((r) => (r.slot ?? 0) === slot);
|
||||
const paneCtx = useContextMenu<number>();
|
||||
// A header-menu item: close the menu, then act on the pane it was opened on.
|
||||
const pickPane = (fn: (slot: number) => void) => () => {
|
||||
const slot = paneCtx.menu?.item;
|
||||
paneCtx.close();
|
||||
if (slot !== undefined) fn(slot);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const end = () => {
|
||||
@@ -310,6 +403,7 @@ export function PaneGrid({
|
||||
data-testid={`pane-header-${slot}`}
|
||||
draggable
|
||||
onDragStart={(e) => startPaneDrag(e, slot)}
|
||||
onContextMenu={(e) => paneCtx.open(e, slot)}
|
||||
title={t("panes.drag")}
|
||||
className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${
|
||||
focused ? "text-fg-primary" : "text-fg-muted"
|
||||
@@ -349,61 +443,15 @@ export function PaneGrid({
|
||||
}
|
||||
</PaneMenu>
|
||||
<div className="ml-auto flex items-center">
|
||||
{(["right", "bottom"] as const).map((edge) => (
|
||||
<PaneMenu
|
||||
key={edge}
|
||||
testId={`pane-split-${edge}-${slot}`}
|
||||
label={full ? t("panes.full") : t(`panes.split.${edge}`)}
|
||||
disabled={full}
|
||||
icon={
|
||||
edge === "right" ? (
|
||||
<Columns2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Rows2 className="h-3.5 w-3.5" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{(close) => (
|
||||
<>
|
||||
{runs
|
||||
.filter((x) => !shown.includes(x.slot ?? 0))
|
||||
.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={ITEM}
|
||||
onClick={() => {
|
||||
close();
|
||||
onChange((l) => moveInLayout(l, x.slot ?? 0, slot, edge));
|
||||
}}
|
||||
>
|
||||
<RunIcon run={x} label={label(x)} />
|
||||
<span className="min-w-0 truncate">{label(x)}</span>
|
||||
</button>
|
||||
))}
|
||||
{runs.some((x) => !shown.includes(x.slot ?? 0)) && (
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={`pane-split-new-${edge}-${slot}`}
|
||||
disabled={!canStart}
|
||||
title={canStart ? undefined : t("tabs.full")}
|
||||
className={`${ITEM} disabled:opacity-40 disabled:hover:bg-transparent`}
|
||||
onClick={() => {
|
||||
close();
|
||||
onNewSplit(slot, edge);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("panes.newTerminal")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</PaneMenu>
|
||||
))}
|
||||
<SplitMenus
|
||||
slot={slot}
|
||||
shown={shown}
|
||||
runs={runs}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
canStart={canStart}
|
||||
onNewSplit={onNewSplit}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pane-close-${slot}`}
|
||||
@@ -423,6 +471,42 @@ export function PaneGrid({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{paneCtx.menu && (
|
||||
<ContextMenu
|
||||
x={paneCtx.menu.x}
|
||||
y={paneCtx.menu.y}
|
||||
onClose={paneCtx.close}
|
||||
testId={`pane-menu-${paneCtx.menu.item}`}
|
||||
className="w-64"
|
||||
>
|
||||
{runs
|
||||
.filter((x) => (x.slot ?? 0) !== paneCtx.menu?.item)
|
||||
.map((x) => (
|
||||
<MenuItem
|
||||
key={x.id}
|
||||
onSelect={pickPane((slot) => onChange((l) => swapInLayout(l, x.slot ?? 0, slot)))}
|
||||
>
|
||||
<RunIcon run={x} label={label(x)} />
|
||||
<span className="min-w-0 truncate">{t("panes.show", { name: label(x) })}</span>
|
||||
</MenuItem>
|
||||
))}
|
||||
{runs.length > 1 && <MenuSeparator />}
|
||||
{(["right", "bottom"] as const).map((edge) => (
|
||||
<MenuItem
|
||||
key={edge}
|
||||
testId={`pane-menu-split-${edge}`}
|
||||
disabled={full || !canStart}
|
||||
onSelect={pickPane((slot) => onNewSplit(slot, edge))}
|
||||
>
|
||||
{t(`panes.splitNew.${edge}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
||||
{t("panes.closeShort")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{dividers.map((d) => (
|
||||
<Divider
|
||||
key={d.path}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file RunSetup.tsx
|
||||
* @description The run-form pieces the rest of the app reuses — the pane's
|
||||
* @description The run-form pieces the rest of the app reuses (an attached
|
||||
* image's right-click offers View / Remove) — the pane's
|
||||
* own run form is gone, a new session starts from the workspace home's prompt
|
||||
* box (`WorkspaceHome.tsx`):
|
||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
||||
import type { LaneTool } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
@@ -262,6 +264,7 @@ export function PromptEditor({
|
||||
// added to the prompt only when it is sent (promptWithImages).
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||
const attachImages = (list: FileList | null | undefined) => {
|
||||
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
|
||||
if (picked.length === 0) return false;
|
||||
@@ -342,6 +345,7 @@ export function PromptEditor({
|
||||
aria-label={img.name}
|
||||
title={img.name}
|
||||
onClick={() => setViewing(img)}
|
||||
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
|
||||
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
|
||||
>
|
||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||
@@ -357,6 +361,27 @@ export function PromptEditor({
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{imageMenu && (
|
||||
<ContextMenu x={imageMenu.x} y={imageMenu.y} onClose={() => setImageMenu(null)}>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
setImageMenu(null);
|
||||
setViewing(imageMenu.img);
|
||||
}}
|
||||
>
|
||||
{t("drop.view")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
setImageMenu(null);
|
||||
removeImage(imageMenu.img);
|
||||
}}
|
||||
>
|
||||
{t("drop.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
|
||||
Reference in New Issue
Block a user