Compare commits
3 Commits
6385c20f91
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 636ef5dcc2 | |||
| 56fe4e734d | |||
| 35719e0961 |
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user