feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)

- Changes panel: Files and Changes merged into one panel; the overview's
  groups drawn as a folder tree (compacted chains, file counts, fold,
  stage/unstage a whole folder)
- AI-suggested commit message for what is staged
  (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku)
- AI Router: optional "Behaves as" Claude model id, written as
  modelPicker options into the router's --settings file (issue #56)
- Sidebar: session rows show last activity, live rows their start time;
  a closed Terminal row can be removed (DELETE /api/run/history/:id)
- Every pane's CLI starts only once a client is attached; claude panes
  no longer forced inline; tmux scrollbar one column wide
- Update notifier: "Check for updates" reopens a closed notice
This commit is contained in:
2026-10-06 12:36:06 +07:00
parent 6c605849d8
commit f0500dc592
41 changed files with 1201 additions and 249 deletions
@@ -14,7 +14,8 @@
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
* which never has a session) as **Terminal** — and under the sessions the last
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
* click opens a NEW shell tab (a shell cannot be resumed); Ctrl/Cmd+click opens
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or
* right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens
* it in a split beside the focused pane) — attaching, never starting or
* resuming anything;
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
@@ -31,7 +32,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
import { Archive, GitBranch, SquareTerminal, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
@@ -42,7 +43,7 @@ import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { useAiTools } from "../../hooks/useAiTools";
import { useContextMenu } from "../ContextMenu";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu";
export const SESSION_LIMIT = 6;
@@ -297,6 +298,15 @@ export function WorkspaceSessionList({
toast.error(err);
}
};
const closedCtx = useContextMenu<RunHistoryItem>();
const forgetShell = async (r: RunHistoryItem) => {
try {
await api.run.forgetHistory(r.id);
setClosedShells((all) => all.filter((x) => x.id !== r.id));
} catch (err) {
toast.error(err);
}
};
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
const sessions = own?.sessions ?? (own === null ? null : []);
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
@@ -458,19 +468,19 @@ export function WorkspaceSessionList({
>
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
<span className="sr-only">{t("tree.state.live")}</span>
<span className={`truncate ${r.tool === "shell" ? "" : "italic"}`}>
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
</span>
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
{r.startedAt ? timeAgo(r.startedAt) : null}
</span>
{r.tool === "shell" ? (
<>
<span className="truncate">{t("tabs.terminal")}</span>
<SquareTerminal aria-hidden className="ml-auto h-3.5 w-3.5 shrink-0 opacity-70" />
</>
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
) : (
<>
<span className="truncate italic">{t("tree.newSession")}</span>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
</>
<AiToolIcon
tool={{ id: r.tool, label: tools.label(r.tool) }}
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70"
/>
)}
</button>
</li>
@@ -554,9 +564,12 @@ export function WorkspaceSessionList({
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
{timeAgo(s.last_activity ?? s.started_at)}
</span>
<AiToolIcon
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
className="ml-auto h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
/>
</button>
<button
@@ -575,11 +588,12 @@ export function WorkspaceSessionList({
);
})}
{closed.map((r) => (
<li key={r.id}>
<li key={r.id} className="group relative">
<button
type="button"
data-testid={`workspace-terminal-closed-${r.id}`}
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
onContextMenu={(e) => closedCtx.open(e, r)}
title={t("tree.closedTerminal")}
className="flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
@@ -592,7 +606,20 @@ export function WorkspaceSessionList({
<span className="ml-auto shrink-0 text-[11px]">
{timeAgo(r.ended_at ?? r.started_at)}
</span>
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
<SquareTerminal
aria-hidden
className="h-3.5 w-3.5 shrink-0 opacity-70 group-hover:invisible"
/>
</button>
<button
type="button"
data-testid={`workspace-terminal-forget-${r.id}`}
onClick={() => void forgetShell(r)}
title={t("tree.forgetTerminal")}
aria-label={t("tree.forgetTerminal")}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</li>
))}
@@ -619,6 +646,42 @@ export function WorkspaceSessionList({
onClose={ctx.close}
/>
)}
{closedCtx.menu && (
<ContextMenu
x={closedCtx.menu.x}
y={closedCtx.menu.y}
onClose={closedCtx.close}
testId={`terminal-menu-${closedCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
closedCtx.close();
void reopenShell(false);
}}
>
{t("tree.reopenTerminal")}
</MenuItem>
<MenuItem
onSelect={() => {
closedCtx.close();
void reopenShell(true);
}}
>
{t("sessionMenu.openSplit")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const item = closedCtx.menu!.item;
closedCtx.close();
void forgetShell(item);
}}
>
{t("tree.forgetTerminal")}
</MenuItem>
</ContextMenu>
)}
{sessionCtx.menu && (
<SessionMenu
x={sessionCtx.menu.x}