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:
@@ -39,8 +39,9 @@ import { useDialog } from "../hooks/useDialog";
|
||||
|
||||
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
type FormState = Omit<AiRouterInput, "apiKey" | "models"> & {
|
||||
type FormState = Omit<AiRouterInput, "apiKey" | "models" | "behavesAs"> & {
|
||||
apiKey: string;
|
||||
behavesAs: string;
|
||||
models: Partial<AiRouterModels>;
|
||||
};
|
||||
|
||||
@@ -50,6 +51,7 @@ const emptyForm = (): FormState => ({
|
||||
apiKey: "",
|
||||
model: "",
|
||||
models: {},
|
||||
behavesAs: "",
|
||||
});
|
||||
|
||||
export function AiRouterConfig() {
|
||||
@@ -121,6 +123,7 @@ export function AiRouterConfig() {
|
||||
apiKey: "",
|
||||
model: item.model,
|
||||
models: { ...item.models },
|
||||
behavesAs: item.behavesAs ?? "",
|
||||
});
|
||||
setFormError(null);
|
||||
setOpen(true);
|
||||
@@ -175,6 +178,7 @@ export function AiRouterConfig() {
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
models,
|
||||
behavesAs: form.behavesAs.trim(),
|
||||
};
|
||||
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
||||
|
||||
@@ -370,6 +374,15 @@ export function AiRouterConfig() {
|
||||
mono
|
||||
onChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
||||
/>
|
||||
<Field
|
||||
id="router-behaves-as"
|
||||
label={t("aiRouter.fieldBehavesAs")}
|
||||
hint={t("aiRouter.fieldBehavesAsHint")}
|
||||
value={form.behavesAs}
|
||||
mono
|
||||
placeholder="claude-opus-5-5"
|
||||
onChange={(v) => setForm((f) => ({ ...f, behavesAs: v }))}
|
||||
/>
|
||||
|
||||
<details className="rounded-lg border border-border bg-surface-2 p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-secondary">
|
||||
|
||||
@@ -61,8 +61,6 @@ import {
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
FolderTree,
|
||||
GitCompare,
|
||||
ChartColumn,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -120,17 +118,9 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||
/** localStorage key for how wide the Changes panel is. */
|
||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** localStorage key for the right panel's activity-bar tab. */
|
||||
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
|
||||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||||
const SELECTED_KEY = "agenthub.workspace.selected";
|
||||
|
||||
const PANEL_TABS = [
|
||||
{ id: "files", Icon: FolderTree },
|
||||
{ id: "changes", Icon: GitCompare },
|
||||
] as const;
|
||||
type PanelTab = (typeof PANEL_TABS)[number]["id"];
|
||||
|
||||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
||||
* diff and watching a console want opposite amounts of room, and which one is
|
||||
* "work" changes several times an hour — hence ONE control that steps
|
||||
@@ -600,21 +590,6 @@ export function Workspace() {
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [paneHasActiveRun, setPaneHasActiveRun] = useState(false);
|
||||
|
||||
const [panelTab, setPanelTab] = useState<PanelTab>(() => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(PANEL_TAB_KEY) as PanelTab | null;
|
||||
return saved && PANEL_TABS.some((x) => x.id === saved) ? saved : "changes";
|
||||
} catch {
|
||||
return "changes";
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
|
||||
} catch {
|
||||
/* the panel just forgets its tab between visits */
|
||||
}
|
||||
}, [panelTab]);
|
||||
// Whether the Changes panel is open. A viewer preference, so it lives in
|
||||
// localStorage and never reaches the server — same rule as the lane strip's
|
||||
// order (client/src/lib/laneOrderStorage.ts).
|
||||
@@ -1231,36 +1206,17 @@ export function Workspace() {
|
||||
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
|
||||
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
|
||||
>
|
||||
{/* Activity bar: Files · Changes · Sessions, then the resize
|
||||
control — the same height as the console's tab bar, so the
|
||||
two read as one header strip. */}
|
||||
{/* Header: the title, Analyst, then the resize control — the
|
||||
same height as the console's tab bar, so the two read as
|
||||
one header strip. Files and Changes are ONE panel now; its
|
||||
own mode switcher moves between them. */}
|
||||
<div
|
||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
||||
role="toolbar"
|
||||
>
|
||||
{PANEL_TABS.map(({ id, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
data-testid={`panel-tab-${id}`}
|
||||
aria-pressed={panelTab === id}
|
||||
// Narrow, a tab switch leaves the open file (and its
|
||||
// edit); at full width the editor stays put.
|
||||
onClick={() =>
|
||||
panelTab !== id &&
|
||||
(changesFull ? setPanelTab(id) : afterDrop(() => setPanelTab(id)))
|
||||
}
|
||||
title={tLanes(`panel.${id}`)}
|
||||
aria-label={tLanes(`panel.${id}`)}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
||||
panelTab === id
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||
{tLanes("panel.changes")}
|
||||
</span>
|
||||
{/* The opened session's analysis page — a page of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1318,7 +1274,6 @@ export function Workspace() {
|
||||
key={panelKey}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
tab={panelTab}
|
||||
full={changesFull}
|
||||
onDirtyChange={setChangesDirty}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
|
||||
@@ -104,11 +104,30 @@ describe("AiRouterConfig", () => {
|
||||
// Every tier equals the headline model, so none is sent: the server
|
||||
// fills them, which is what makes one field enough.
|
||||
models: {},
|
||||
behavesAs: "",
|
||||
apiKey: "sk-typed",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the typed behavesAs, which maps the router's model names in Claude Code (#56)", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "agent");
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBehavesAs"), "claude-opus-5-5");
|
||||
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.aiRouters.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("sends only the tiers that differ from the headline model", async () => {
|
||||
render(<AiRouterConfig />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -6981,7 +6981,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.1.0 (rebuild client to match)
|
||||
UI build v2.2.0 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user