3 Commits

Author SHA1 Message Date
alex 636ef5dcc2 fix(quick-pane): recover catalog after bridge restarts
Treat missing stale runtimes as already closed and defer thinking reconciliation until the refreshed model catalog is ready.
2026-09-02 10:07:04 +02:00
alex 56fe4e734d fix(bridge): restore labels from session history
Hydrate missing runtime names from persisted session metadata so opened and restored conversations no longer remain titled New session.
2026-09-02 10:06:55 +02:00
alex 35719e0961 feat(ui): streamline multi-session workflows
Keep working status at the transcript tail, replace the tab strip with accessible overflow navigation, focus the prompt on activation and new sessions, and add session keyboard shortcuts.
2026-09-02 10:06:26 +02:00
10 changed files with 663 additions and 133 deletions
+7
View File
@@ -566,6 +566,13 @@ export function createAgentRegistry({
if (typeof state.sessionName === "string")
runtime.sessionName = state.sessionName;
if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
if (resolved && !runtime.sessionName && !runtime.firstMessage) {
const summary = await readSessionSummary(resolved, resolved).catch(
() => undefined,
);
if (summary?.name) runtime.sessionName = summary.name;
if (summary?.firstMessage) runtime.firstMessage = summary.firstMessage;
}
if (resolved) {
if (indexSession) {
if (
+17 -1
View File
@@ -177,9 +177,16 @@ test("closing removes desired-open intent, preserves history, and refuses direct
await registry.stop();
});
test("restores durable runtime IDs with two-at-a-time concurrency and preserves command isolation", async () => {
test("restores durable runtime IDs and history labels with two-at-a-time concurrency", async () => {
const paths = await fixture();
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "one");
await writeFile(
firstPath,
`${await readFile(firstPath, "utf8")}${JSON.stringify({
type: "message",
message: { role: "user", content: "Hydrate this session label" },
})}\n`,
);
const secondPath = await sessionFile(paths.sessionRoot, paths.home, "two");
const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "three");
const firstAdapters = adapterFactory({
@@ -195,6 +202,7 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves
if (!initial.sessionPath)
await firstRegistry.closeSessionRuntime(initial.runtimeId);
const one = await firstRegistry.openSessionRuntime(paths.home, firstPath);
assert.equal(one.label, "Hydrate this session label");
const two = await firstRegistry.openSessionRuntime(paths.home, secondPath);
const three = await firstRegistry.openSessionRuntime(paths.home, thirdPath);
await firstRegistry.stop();
@@ -241,6 +249,14 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves
new Set([one.runtimeId, two.runtimeId, three.runtimeId]),
);
assert.equal(maxActiveStarts, 2);
assert.equal(
restored
.getWorkspace()
.directories[0].runtimes.find(
(runtime) => runtime.runtimeId === one.runtimeId,
)?.label,
"Hydrate this session label",
);
assert.ok(
restored
.listAgents()
+163 -79
View File
@@ -224,46 +224,92 @@ select:focus-visible {
gap: 5px;
}
.session-strip {
display: block;
min-width: 0;
}
.session-tab-bar {
display: flex;
align-items: end;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 28px auto;
align-items: center;
gap: 4px;
min-width: 0;
border-bottom: 1px solid #55585d;
padding: 4px;
border: 1px solid #484b50;
border-radius: 9px;
background: #242528;
box-shadow: inset 0 1px rgba(255, 255, 255, 0.025);
}
.session-scroll-button {
display: grid;
place-items: center;
width: 28px;
height: 44px;
padding: 0;
border: 1px solid #4b4e53;
border-radius: 7px;
background: #303237;
color: #c8ccd3;
font-size: 21px;
line-height: 1;
}
.session-scroll-button:hover:not(:disabled) {
border-color: #8a6339;
background: #3d372f;
color: #ffc170;
}
.session-scroll-button:disabled {
opacity: 0.25;
}
.session-tabs {
min-width: 0;
flex: 0 1 auto;
display: flex;
min-width: 0;
gap: 5px;
overflow-x: auto;
gap: 4px;
padding-left: 4px;
overflow-y: hidden;
padding: 2px;
scroll-behavior: smooth;
scroll-padding-inline: 2px;
scroll-snap-type: x proximity;
scrollbar-color: #696057 transparent;
scrollbar-width: thin;
}
.session-tabs::-webkit-scrollbar {
height: 5px;
}
.session-tabs::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #696057;
}
.session-tab {
display: grid;
grid-template-columns: minmax(0, 1fr) 28px 28px;
align-items: stretch;
flex: 0 0 auto;
grid-template-columns: minmax(0, 1fr) 27px 27px;
width: 188px;
height: 44px;
flex: 0 0 188px;
overflow: hidden;
border: 1px solid #555;
border-bottom: 0;
border-radius: 7px 7px 0 0;
background: #303136;
scroll-snap-align: start;
border: 1px solid #4d5055;
border-radius: 7px;
background: #303237;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22);
}
.session-tab:hover,
.session-tab:focus-within {
border-color: #696d73;
background: #35373c;
}
.session-tab.selected {
border-color: #d88735;
background: #3b3935;
box-shadow: inset 0 2px #f0a347;
background: #3c362f;
box-shadow:
inset 3px 0 #f0a347,
0 0 0 1px rgba(240, 163, 71, 0.12);
}
.session-tab > button[role="tab"] {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
min-width: 112px;
max-width: 176px;
padding: 6px 9px 5px;
min-width: 0;
align-content: center;
gap: 3px;
padding: 5px 7px 5px 10px;
border: 0;
border-radius: 0;
background: transparent;
@@ -271,44 +317,69 @@ select:focus-visible {
text-align: left;
}
.session-tab > button[role="tab"]:hover {
background: #383a40;
background: rgba(255, 255, 255, 0.025);
}
.session-tab.selected > button[role="tab"] {
color: #fff5e7;
}
.session-tab > button span {
.session-tab-label {
overflow: hidden;
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-tab small {
grid-row: 2;
color: #788077;
font-size: 10px;
.session-tab-meta {
display: flex;
min-width: 0;
align-items: center;
gap: 5px;
}
.session-tab small.streaming,
.session-tab small.recovering {
color: #ffc170;
.session-tab-meta small {
overflow: hidden;
color: #9299a2;
font-size: 9px;
letter-spacing: 0.035em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.session-state-dot {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: #737a75;
}
.session-state-dot.streaming,
.session-state-dot.working,
.session-state-dot.recovering {
background: #f0a347;
box-shadow: 0 0 6px rgba(240, 163, 71, 0.7);
}
.session-state-dot.error,
.session-state-dot.failed {
background: #e47682;
}
.renew-session,
.close-session {
display: grid;
place-items: center;
width: 28px;
width: 27px;
min-width: 0;
align-self: stretch;
border: 0;
border-left: 1px solid #45484d;
border-radius: 0;
padding: 0;
background: transparent;
color: #b8bbc1;
background: rgba(18, 19, 21, 0.2);
color: #aeb3bb;
}
.renew-session {
color: #a9f2df;
color: #9bcfbe;
}
.renew-session-icon {
width: 15px;
height: 15px;
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 2;
@@ -328,65 +399,67 @@ select:focus-visible {
}
.session-tab.selected .renew-session,
.session-tab.selected .close-session {
color: #fff5e7;
border-left-color: #594d40;
color: #f3deca;
}
.renew-session:hover {
.renew-session:hover:not(:disabled) {
background: #294139;
color: #c8ffef;
}
.close-session:hover {
.close-session:hover:not(:disabled) {
background: #4a3131;
color: #ffd7db;
}
.new-session-tab {
align-self: stretch;
flex: 0 0 32px;
margin-left: 4px;
padding: 0;
border: 1px solid #d88735;
border-bottom: 0;
border-radius: 8px 8px 0 0;
background: #4a3827;
color: #ffc170;
font-size: 18px;
line-height: 1;
}
.new-session-tab:hover {
background: #5a432d;
color: #ffe0b6;
}
.unread-dot {
width: 7px;
height: 7px;
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: #f0a347;
}
.attention-dot {
color: #ffcb6b;
font-size: 10px;
}
.session-tab-actions {
display: flex;
gap: 3px;
margin-left: auto;
padding: 0 0 2px 8px;
align-self: stretch;
gap: 4px;
padding-left: 2px;
border-left: 1px solid #424449;
}
.session-tab-actions button {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 44px;
white-space: nowrap;
border: 1px solid #55585d;
border-bottom: 0;
border-radius: 6px 6px 0 0;
padding: 5px 8px;
background: #383a40;
color: #e2e4e8;
border: 1px solid #51545a;
border-radius: 7px;
padding: 0 9px;
background: #303237;
color: #dde0e5;
}
.session-tab-actions button:hover {
background: #4a4540;
.session-tab-actions button:hover:not(:disabled) {
border-color: #8a6339;
background: #3d372f;
color: #ffc170;
}
.session-tab-actions span {
margin-right: 5px;
font-size: 15px;
line-height: 0;
.session-tab-actions .new-session-tab {
border-color: #9a6938;
background: #443526;
color: #ffc170;
}
.session-tab-actions button > span:first-child {
font-size: 16px;
line-height: 1;
}
.session-action-label {
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.directory-add {
display: flex;
@@ -1187,11 +1260,22 @@ dialog.extension:not([open]) {
.workflow-main {
grid-template-columns: minmax(0, 1fr) 165px;
}
.session-strip-actions {
flex-direction: column;
.session-tab-bar {
grid-template-columns: 26px minmax(0, 1fr) 26px auto;
}
.session-tab > button[role="tab"] {
min-width: 125px;
.session-scroll-button {
width: 26px;
}
.session-tab {
width: 164px;
flex-basis: 164px;
}
.session-tab-actions button {
width: 34px;
padding: 0;
}
.session-action-label {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
+99 -1
View File
@@ -135,13 +135,38 @@ describe("App command progress and errors", () => {
expect(status).toHaveTextContent("Ready for your next prompt");
});
it("starts a session in selected directory with Ctrl+N", async () => {
it("focuses the prompt when the GUI becomes active", async () => {
let state = workspaceState();
state.windowActive = false;
mocks.useWorkspace.mockImplementation(() => ({
state,
dispatch: mocks.dispatch,
refresh: mocks.refresh,
loadSnapshot: mocks.loadSnapshot,
}));
const rendered = render(<App />);
const prompt = screen.getByLabelText("Prompt");
const sessionTab = screen.getByRole("tab", { name: /Session A/ });
sessionTab.focus();
expect(sessionTab).toHaveFocus();
state = { ...state, windowActive: true };
rendered.rerender(<App />);
await waitFor(() => expect(prompt).toHaveFocus());
});
it("starts a session in selected directory with Ctrl+N and focuses the prompt", async () => {
mocks.invoke.mockImplementation((command: string) => {
if (command === "create_session_runtime")
return Promise.resolve({ runtime: { runtimeId: "runtime-new" } });
return Promise.resolve(uiState);
});
render(<App />);
const prompt = screen.getByLabelText("Prompt");
const newSession = screen.getByRole("button", { name: "New session" });
newSession.focus();
expect(newSession).toHaveFocus();
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
@@ -156,6 +181,79 @@ describe("App command progress and errors", () => {
worktreePath: "/repo",
}),
);
await waitFor(() => expect(prompt).toHaveFocus());
});
it("cycles sessions with Ctrl+Tab and Ctrl+Shift+Tab", () => {
const state = workspaceState();
state.runtimesById["runtime-c"] = {
...state.runtimesById["runtime-b"],
summary: {
...otherSummary,
runtimeId: "runtime-c",
agentId: "agent-c",
label: "Session C",
},
};
state.runtimeOrderByDirectory["/repo"] = [
"runtime-a",
"runtime-b",
"runtime-c",
];
mocks.useWorkspace.mockReturnValue({
state,
dispatch: mocks.dispatch,
refresh: mocks.refresh,
loadSnapshot: mocks.loadSnapshot,
});
render(<App />);
mocks.dispatch.mockClear();
const next = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
ctrlKey: true,
key: "Tab",
});
window.dispatchEvent(next);
expect(next.defaultPrevented).toBe(true);
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "selectRuntime",
runtimeId: "runtime-b",
});
mocks.dispatch.mockClear();
const previous = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
ctrlKey: true,
shiftKey: true,
key: "Tab",
});
window.dispatchEvent(previous);
expect(previous.defaultPrevented).toBe(true);
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "selectRuntime",
runtimeId: "runtime-c",
});
});
it("closes the selected session with Ctrl+W", async () => {
render(<App />);
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
ctrlKey: true,
key: "w",
});
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("close_session_runtime", {
runtimeId: "runtime-a",
}),
);
});
it("loads categorized snapshot selectors and derives providers from choices", async () => {
+56 -5
View File
@@ -159,6 +159,7 @@ export default function App() {
() => new Set(),
);
const [restoreTabFocus, setRestoreTabFocus] = useState(false);
const [promptFocusRequest, setPromptFocusRequest] = useState(0);
const [uiState, setUiState] = useState<UiState>();
const [quickPaneSettings, setQuickPaneSettings] = useState<QuickPaneSettings>(
defaultQuickPaneSettings,
@@ -431,6 +432,9 @@ export default function App() {
dispatch({ type: "markRead", runtimeId: selectedRuntimeId }),
);
}, [selectedRuntimeId, dispatch]);
useEffect(() => {
if (state.windowActive) setPromptFocusRequest((current) => current + 1);
}, [state.windowActive]);
useEffect(() => {
setAbortingRuntimeIds((current) => {
@@ -491,7 +495,10 @@ export default function App() {
if (!mountedRef.current) return;
dispatch({ type: "selectRuntime", runtimeId: result.runtime.runtimeId });
await loadSnapshot(result.runtime.runtimeId);
if (mountedRef.current) setStatus("New session ready");
if (mountedRef.current) {
setStatus("New session ready");
setPromptFocusRequest((current) => current + 1);
}
} catch (error) {
if (mountedRef.current)
setStatus(`Could not start session: ${String(error)}`);
@@ -894,17 +901,56 @@ export default function App() {
event.repeat ||
!event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.key.toLowerCase() !== "n" ||
sessionTabsBusy
event.altKey
)
return;
const key = event.key.toLowerCase();
if (key === "n") {
if (sessionTabsBusy) return;
event.preventDefault();
void createRuntime();
return;
}
if (key === "w") {
event.preventDefault();
if (
!selected ||
activeOperations.some(
(entry) =>
entry.runtimeId === selected.summary.runtimeId &&
["closing", "renewing", "restarting"].includes(entry.kind),
)
)
return;
void closeRuntime(selected);
return;
}
if (key !== "tab") return;
event.preventDefault();
if (runtimes.length < 2) return;
const current = runtimes.findIndex(
(runtime) => runtime.summary.runtimeId === selectedRuntimeId,
);
const direction = event.shiftKey ? -1 : 1;
const next =
current < 0 ? 0 : (current + direction + runtimes.length) % runtimes.length;
dispatch({
type: "selectRuntime",
runtimeId: runtimes[next].summary.runtimeId,
});
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [createRuntime, sessionTabsBusy]);
}, [
activeOperations,
closeRuntime,
createRuntime,
dispatch,
runtimes,
selected,
selectedRuntimeId,
sessionTabsBusy,
]);
const closingRuntimeIds = new Set(
activeOperations
@@ -1277,6 +1323,11 @@ export default function App() {
) : selected ? (
<ConversationWorkspace
runtime={selected}
focusRequest={
adding || sessions || visibleCommandForm || selected.extensions.length
? 0
: promptFocusRequest
}
onDraft={(draft) =>
dispatch({
type: "draftChanged",
+31 -17
View File
@@ -141,6 +141,7 @@ export function ConversationWorkspace({
onScroll,
onModel,
onThinking,
focusRequest = 0,
isSubmitting = false,
isAborting = false,
isModelUpdating = false,
@@ -156,6 +157,7 @@ export function ConversationWorkspace({
onScroll: (atBottom: boolean, scrollTop: number) => void;
onModel: (model: Model) => Promise<boolean>;
onThinking: (level: string) => Promise<boolean>;
focusRequest?: number;
isSubmitting?: boolean;
isAborting?: boolean;
isModelUpdating?: boolean;
@@ -165,6 +167,7 @@ export function ConversationWorkspace({
onCommand: (command: RpcCommand) => void;
}) {
const ref = useRef<HTMLElement>(null);
const promptRef = useRef<HTMLTextAreaElement>(null);
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
const runtimeIsWorking =
runtime.summary.state === "streaming" || runtime.progress.phase === "working";
@@ -215,7 +218,17 @@ export function ConversationWorkspace({
useEffect(() => {
if (runtime.scroll.atBottom && ref.current)
ref.current.scrollTop = ref.current.scrollHeight;
}, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]);
}, [
runtime.messages,
runtime.pendingSubmissions,
runtime.scroll.atBottom,
isWorking,
runtime.progress.detail,
runtime.progress.tool,
]);
useEffect(() => {
if (focusRequest > 0 && !isSubmitting && !locked) promptRef.current?.focus();
}, [focusRequest, isSubmitting, locked]);
const scrollToLatest = () => {
const transcript = ref.current;
if (!transcript) return;
@@ -246,22 +259,6 @@ export function ConversationWorkspace({
);
}}
>
{isWorking && (
<div className="agent-working" role="status" aria-live="polite">
<span className="working-bars" aria-hidden="true">
<i />
<i />
<i />
</span>
<div>
<strong>Pi is working</strong>
<span>
{isSubmitting ? "Sending your prompt…" : runtime.progress.detail}
</span>
{runtime.progress.tool && <small>Using {runtime.progress.tool}</small>}
</div>
</div>
)}
{runtime.loading && !runtime.messages.length && (
<p className="muted">Loading session…</p>
)}
@@ -282,6 +279,22 @@ export function ConversationWorkspace({
<pre>{pending.text}</pre>
</article>
))}
{isWorking && (
<div className="agent-working" role="status" aria-live="polite">
<span className="working-bars" aria-hidden="true">
<i />
<i />
<i />
</span>
<div>
<strong>Pi is working</strong>
<span>
{isSubmitting ? "Sending your prompt…" : runtime.progress.detail}
</span>
{runtime.progress.tool && <small>Using {runtime.progress.tool}</small>}
</div>
</div>
)}
</section>
{!runtime.scroll.atBottom && (
<button
@@ -416,6 +429,7 @@ export function ConversationWorkspace({
/>
<textarea
aria-label="Prompt"
ref={promptRef}
value={runtime.draft}
disabled={isSubmitting || locked}
onChange={(event) => onDraft(event.currentTarget.value)}
+119 -11
View File
@@ -1,4 +1,10 @@
import { useEffect, useRef, type KeyboardEvent } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent,
} from "react";
import type { RuntimeView } from "../workspace/types";
type Props = {
@@ -15,6 +21,7 @@ type Props = {
restoreFocus?: boolean;
onFocusRestored?: () => void;
};
export function SessionTabs({
runtimes,
selectedId,
@@ -30,12 +37,63 @@ export function SessionTabs({
onFocusRestored,
}: Props) {
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
const tabsRef = useRef<HTMLDivElement>(null);
const newSessionRef = useRef<HTMLButtonElement>(null);
const [scrollPosition, setScrollPosition] = useState({
left: false,
right: false,
});
const syncScrollPosition = useCallback(() => {
const tabs = tabsRef.current;
if (!tabs) return;
const next = {
left: tabs.scrollLeft > 1,
right: tabs.scrollLeft + tabs.clientWidth < tabs.scrollWidth - 1,
};
setScrollPosition((current) =>
current.left === next.left && current.right === next.right ? current : next,
);
}, []);
const reveal = useCallback((runtimeId: string) => {
refs.current[runtimeId]?.scrollIntoView?.({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
}, []);
useEffect(() => {
if (selectedId) reveal(selectedId);
}, [selectedId, reveal]);
useEffect(() => {
const tabs = tabsRef.current;
if (!tabs) return;
syncScrollPosition();
const observer =
typeof ResizeObserver === "undefined"
? undefined
: new ResizeObserver(syncScrollPosition);
observer?.observe(tabs);
window.addEventListener("resize", syncScrollPosition);
return () => {
observer?.disconnect();
window.removeEventListener("resize", syncScrollPosition);
};
}, [runtimes.length, syncScrollPosition]);
useEffect(() => {
if (!restoreFocus) return;
(selectedId ? refs.current[selectedId] : newSessionRef.current)?.focus();
const target = selectedId ? refs.current[selectedId] : newSessionRef.current;
target?.focus();
if (selectedId) reveal(selectedId);
onFocusRestored?.();
}, [restoreFocus, selectedId, onFocusRestored]);
}, [restoreFocus, selectedId, onFocusRestored, reveal]);
function scrollTabs(direction: -1 | 1) {
const tabs = tabsRef.current;
if (!tabs) return;
tabs.scrollBy({
behavior: "smooth",
left: direction * Math.max(tabs.clientWidth * 0.75, 180),
});
}
function keydown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
let next: number | undefined;
if (!runtimes.length) return;
@@ -46,13 +104,45 @@ export function SessionTabs({
else if (event.key === "End") next = runtimes.length - 1;
if (next === undefined) return;
event.preventDefault();
refs.current[runtimes[next].summary.runtimeId]?.focus();
onSelect(runtimes[next].summary.runtimeId);
const runtimeId = runtimes[next].summary.runtimeId;
refs.current[runtimeId]?.focus();
reveal(runtimeId);
onSelect(runtimeId);
}
return (
<div className="session-strip">
<div className="session-tab-bar" aria-label="Sessions">
<div className="session-tabs" role="tablist" aria-label="Open sessions">
<button
type="button"
className="session-scroll-button"
aria-label="Scroll sessions left"
title="Previous sessions"
disabled={!scrollPosition.left}
onClick={() => scrollTabs(-1)}
>
<span aria-hidden="true">‹</span>
</button>
<div
className="session-tabs"
role="tablist"
aria-label="Open sessions"
ref={tabsRef}
onScroll={syncScrollPosition}
onWheel={(event) => {
const tabs = event.currentTarget;
if (
Math.abs(event.deltaY) <= Math.abs(event.deltaX) ||
tabs.scrollWidth <= tabs.clientWidth
)
return;
const atStart = tabs.scrollLeft <= 0;
const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 1;
if ((event.deltaY < 0 && atStart) || (event.deltaY > 0 && atEnd)) return;
event.preventDefault();
tabs.scrollLeft += event.deltaY;
syncScrollPosition();
}}
>
{runtimes.map((runtime, index) => {
const summary = runtime.summary;
const selected = summary.runtimeId === selectedId;
@@ -62,6 +152,7 @@ export function SessionTabs({
<div
className={`session-tab ${selected ? "selected" : ""}`}
key={summary.runtimeId}
role="presentation"
>
<button
role="tab"
@@ -72,11 +163,17 @@ export function SessionTabs({
ref={(element) => {
refs.current[summary.runtimeId] = element;
}}
onFocus={() => reveal(summary.runtimeId)}
onKeyDown={(event) => keydown(event, index)}
onClick={() => onSelect(summary.runtimeId)}
>
<span>{summary.label}</span>
<small className={summary.state}>
<span className="session-tab-label">{summary.label}</span>
<span className="session-tab-meta">
<i
className={`session-state-dot ${summary.state}`}
aria-hidden="true"
/>
<small>
{summary.state}
{summary.queueCount ? ` · ${summary.queueCount} queued` : ""}
</small>
@@ -88,6 +185,7 @@ export function SessionTabs({
!
</b>
)}
</span>
</button>
<button
className="renew-session"
@@ -126,20 +224,30 @@ export function SessionTabs({
);
})}
</div>
<button
type="button"
className="session-scroll-button"
aria-label="Scroll sessions right"
title="More sessions"
disabled={!scrollPosition.right}
onClick={() => scrollTabs(1)}
>
<span aria-hidden="true">›</span>
</button>
<div className="session-tab-actions" aria-label="Session actions">
<button
className="new-session-tab"
ref={newSessionRef}
aria-label={busy ? "Starting new session" : "New session"}
title="New session"
disabled={busy}
onClick={onNew}
>
<span aria-hidden="true">{busy ? "…" : "+"}</span>
<span className="session-action-label">{busy ? "Starting" : "New"}</span>
</button>
<div className="session-tab-actions" aria-label="Session actions">
<button className="load-session" disabled={busy} onClick={onResume}>
<span aria-hidden="true">⌄</span>
{busy ? "Working…" : "Load"}
<span className="session-action-label">{busy ? "Working…" : "Load"}</span>
</button>
</div>
</div>
+103 -1
View File
@@ -75,7 +75,13 @@ describe("multi-session navigation", () => {
expect(closeFirst.previousElementSibling).toBe(renewFirst);
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
const newSession = screen.getByRole("button", { name: "New session" });
expect(tablist.nextElementSibling).toBe(newSession);
expect(tablist.previousElementSibling).toHaveAccessibleName(
"Scroll sessions left",
);
expect(tablist.nextElementSibling).toHaveAccessibleName(
"Scroll sessions right",
);
expect(newSession.closest(".session-tab-actions")).not.toBeNull();
await userEvent.click(newSession);
expect(onNew).toHaveBeenCalledOnce();
await userEvent.click(tabs[0]);
@@ -94,6 +100,61 @@ describe("multi-session navigation", () => {
);
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
});
it("scrolls overflowing sessions and reveals the selected tab", async () => {
const user = userEvent.setup();
const runtimes = ["a", "b", "c", "d", "e"].map((id) =>
runtimeView(summary(id)),
);
const props = {
runtimes,
onSelect: vi.fn(),
onClose: vi.fn(),
onRenew: vi.fn(),
onNew: vi.fn(),
onResume: vi.fn(),
};
const { rerender } = render(<SessionTabs {...props} selectedId="a" />);
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
const scrollBy = vi.fn();
Object.defineProperties(tablist, {
scrollWidth: { configurable: true, value: 800 },
clientWidth: { configurable: true, value: 240 },
scrollLeft: { configurable: true, value: 0, writable: true },
scrollBy: { configurable: true, value: scrollBy },
});
fireEvent.scroll(tablist);
const previous = screen.getByRole("button", {
name: "Scroll sessions left",
});
const next = screen.getByRole("button", { name: "Scroll sessions right" });
expect(previous).toBeDisabled();
expect(next).toBeEnabled();
await user.click(next);
expect(scrollBy).toHaveBeenCalledWith({ behavior: "smooth", left: 180 });
(tablist as HTMLDivElement).scrollLeft = 300;
fireEvent.wheel(tablist, { deltaX: 0, deltaY: -80 });
expect((tablist as HTMLDivElement).scrollLeft).toBe(220);
(tablist as HTMLDivElement).scrollLeft = 560;
fireEvent.scroll(tablist);
expect(previous).toBeEnabled();
expect(next).toBeDisabled();
const finalTab = screen.getByRole("tab", { name: /^e idle$/ });
const scrollIntoView = vi.fn();
Object.defineProperty(finalTab, "scrollIntoView", {
configurable: true,
value: scrollIntoView,
});
rerender(<SessionTabs {...props} selectedId="e" />);
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
});
it("announces close progress only on the closing session", () => {
render(
<SessionTabs
@@ -260,6 +321,47 @@ describe("multi-session navigation", () => {
expect(modelControls.nextElementSibling).toBe(status);
expect(status.nextElementSibling).toBe(composer);
});
it("keeps working activity at the transcript tail and follows it from the bottom", () => {
const idle = runtimeView(summary("activity"));
idle.messages = [{ role: "assistant", content: "Partial response" }];
const props = {
onDraft: vi.fn(),
onSubmit: vi.fn(),
onAbort: vi.fn(),
onScroll: vi.fn(),
onModel: vi.fn().mockResolvedValue(true),
onThinking: vi.fn().mockResolvedValue(true),
onCommand: vi.fn(),
};
const { rerender } = render(
<ConversationWorkspace runtime={idle} {...props} />,
);
const transcript = screen.getByRole("region", { name: "Conversation" });
Object.defineProperty(transcript, "scrollHeight", {
configurable: true,
value: 600,
});
transcript.scrollTop = 125;
rerender(
<ConversationWorkspace
runtime={{
...idle,
summary: { ...idle.summary, state: "streaming" },
progress: {
phase: "working",
detail: "Thinking and preparing a response",
toolCount: 0,
},
}}
{...props}
/>,
);
const activity = screen.getByText("Pi is working").closest(".agent-working");
expect(transcript.lastElementChild).toBe(activity);
expect(transcript.scrollTop).toBe(600);
});
it("shows a jump control only while scrolled up and moves to the latest message", async () => {
const user = userEvent.setup();
const onScroll = vi.fn();
+46
View File
@@ -459,6 +459,52 @@ describe("QuickPane", () => {
);
});
it("recovers a stale Quick runtime and keeps persisted thinking after bridge restart", async () => {
const thinkingModels: Model[] = [
{
provider: "provider",
id: "quick/model",
reasoning: true,
thinkingLevelMap: { high: "high" },
},
];
const persistedSettings = {
...settings,
defaultThinkingLevel: "high",
};
const tracker = bridgeMock([thinkingModels, thinkingModels], undefined, {
...uiState,
quickPane: persistedSettings,
});
const invoke = mocks.invoke.getMockImplementation();
mocks.invoke.mockImplementation(
(command: string, payload?: { runtimeId?: string }) => {
if (
command === "close_quick_runtime" &&
payload?.runtimeId === "runtime-1"
)
return Promise.reject(
new Error("unknown session runtime: stale-runtime-id"),
);
return invoke?.(command, payload);
},
);
render(<QuickPane />);
await preparedTrigger();
const settingsListener = mocks.listen.mock.calls.find(
([event]) => event === "quick-pane-settings-saved",
)?.[1] as ((event: { payload: QuickPaneSettings }) => void) | undefined;
expect(settingsListener).toBeTypeOf("function");
await act(async () => settingsListener?.({ payload: persistedSettings }));
await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
expect(screen.queryByText(/unknown session runtime/)).toBeNull();
expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveValue(
"high",
);
});
it("derives thinking choices from selected model metadata and falls back to Off", async () => {
const thinkingModels: Model[] = [
{
+5 -1
View File
@@ -341,10 +341,11 @@ export default function QuickPane() {
!!extension;
useEffect(() => {
if (catalogStatus !== "ready") return;
setThinkingLevel((current) =>
reconcileThinkingLevel(selectedCatalogModel, current),
);
}, [selectedCatalogModel]);
}, [catalogStatus, selectedCatalogModel]);
const recordInputSelection = (input: HTMLTextAreaElement) => {
if (
@@ -468,6 +469,9 @@ export default function QuickPane() {
cleanupQueueRef.current.add(runtimeId);
const cleanup = invoke("close_quick_runtime", { runtimeId })
.catch((reason) => {
if (!String(reason).includes("unknown session runtime:")) throw reason;
})
.then(() => {
cleanupQueueRef.current.delete(runtimeId);
})