Compare commits
3 Commits
6385c20f91
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 636ef5dcc2 | |||
| 56fe4e734d | |||
| 35719e0961 |
@@ -566,6 +566,13 @@ export function createAgentRegistry({
|
|||||||
if (typeof state.sessionName === "string")
|
if (typeof state.sessionName === "string")
|
||||||
runtime.sessionName = state.sessionName;
|
runtime.sessionName = state.sessionName;
|
||||||
if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
|
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 (resolved) {
|
||||||
if (indexSession) {
|
if (indexSession) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -177,9 +177,16 @@ test("closing removes desired-open intent, preserves history, and refuses direct
|
|||||||
await registry.stop();
|
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 paths = await fixture();
|
||||||
const firstPath = await sessionFile(paths.sessionRoot, paths.home, "one");
|
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 secondPath = await sessionFile(paths.sessionRoot, paths.home, "two");
|
||||||
const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "three");
|
const thirdPath = await sessionFile(paths.sessionRoot, paths.home, "three");
|
||||||
const firstAdapters = adapterFactory({
|
const firstAdapters = adapterFactory({
|
||||||
@@ -195,6 +202,7 @@ test("restores durable runtime IDs with two-at-a-time concurrency and preserves
|
|||||||
if (!initial.sessionPath)
|
if (!initial.sessionPath)
|
||||||
await firstRegistry.closeSessionRuntime(initial.runtimeId);
|
await firstRegistry.closeSessionRuntime(initial.runtimeId);
|
||||||
const one = await firstRegistry.openSessionRuntime(paths.home, firstPath);
|
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 two = await firstRegistry.openSessionRuntime(paths.home, secondPath);
|
||||||
const three = await firstRegistry.openSessionRuntime(paths.home, thirdPath);
|
const three = await firstRegistry.openSessionRuntime(paths.home, thirdPath);
|
||||||
await firstRegistry.stop();
|
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]),
|
new Set([one.runtimeId, two.runtimeId, three.runtimeId]),
|
||||||
);
|
);
|
||||||
assert.equal(maxActiveStarts, 2);
|
assert.equal(maxActiveStarts, 2);
|
||||||
|
assert.equal(
|
||||||
|
restored
|
||||||
|
.getWorkspace()
|
||||||
|
.directories[0].runtimes.find(
|
||||||
|
(runtime) => runtime.runtimeId === one.runtimeId,
|
||||||
|
)?.label,
|
||||||
|
"Hydrate this session label",
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
restored
|
restored
|
||||||
.listAgents()
|
.listAgents()
|
||||||
|
|||||||
+163
-79
@@ -224,46 +224,92 @@ select:focus-visible {
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
.session-strip {
|
.session-strip {
|
||||||
display: block;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.session-tab-bar {
|
.session-tab-bar {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: end;
|
grid-template-columns: 28px minmax(0, 1fr) 28px auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
min-width: 0;
|
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 {
|
.session-tabs {
|
||||||
min-width: 0;
|
|
||||||
flex: 0 1 auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 5px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
gap: 4px;
|
overflow-y: hidden;
|
||||||
padding-left: 4px;
|
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 {
|
.session-tab {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 28px 28px;
|
grid-template-columns: minmax(0, 1fr) 27px 27px;
|
||||||
align-items: stretch;
|
width: 188px;
|
||||||
flex: 0 0 auto;
|
height: 44px;
|
||||||
|
flex: 0 0 188px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #555;
|
scroll-snap-align: start;
|
||||||
border-bottom: 0;
|
border: 1px solid #4d5055;
|
||||||
border-radius: 7px 7px 0 0;
|
border-radius: 7px;
|
||||||
background: #303136;
|
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 {
|
.session-tab.selected {
|
||||||
border-color: #d88735;
|
border-color: #d88735;
|
||||||
background: #3b3935;
|
background: #3c362f;
|
||||||
box-shadow: inset 0 2px #f0a347;
|
box-shadow:
|
||||||
|
inset 3px 0 #f0a347,
|
||||||
|
0 0 0 1px rgba(240, 163, 71, 0.12);
|
||||||
}
|
}
|
||||||
.session-tab > button[role="tab"] {
|
.session-tab > button[role="tab"] {
|
||||||
position: relative;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
min-width: 0;
|
||||||
min-width: 112px;
|
align-content: center;
|
||||||
max-width: 176px;
|
gap: 3px;
|
||||||
padding: 6px 9px 5px;
|
padding: 5px 7px 5px 10px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -271,44 +317,69 @@ select:focus-visible {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.session-tab > button[role="tab"]:hover {
|
.session-tab > button[role="tab"]:hover {
|
||||||
background: #383a40;
|
background: rgba(255, 255, 255, 0.025);
|
||||||
}
|
}
|
||||||
.session-tab.selected > button[role="tab"] {
|
.session-tab.selected > button[role="tab"] {
|
||||||
color: #fff5e7;
|
color: #fff5e7;
|
||||||
}
|
}
|
||||||
.session-tab > button span {
|
.session-tab-label {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.session-tab small {
|
.session-tab-meta {
|
||||||
grid-row: 2;
|
display: flex;
|
||||||
color: #788077;
|
min-width: 0;
|
||||||
font-size: 10px;
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
.session-tab small.streaming,
|
.session-tab-meta small {
|
||||||
.session-tab small.recovering {
|
overflow: hidden;
|
||||||
color: #ffc170;
|
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,
|
.renew-session,
|
||||||
.close-session {
|
.close-session {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 28px;
|
width: 27px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-self: stretch;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-left: 1px solid #45484d;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: rgba(18, 19, 21, 0.2);
|
||||||
color: #b8bbc1;
|
color: #aeb3bb;
|
||||||
}
|
}
|
||||||
.renew-session {
|
.renew-session {
|
||||||
color: #a9f2df;
|
color: #9bcfbe;
|
||||||
}
|
}
|
||||||
.renew-session-icon {
|
.renew-session-icon {
|
||||||
width: 15px;
|
width: 14px;
|
||||||
height: 15px;
|
height: 14px;
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
@@ -328,65 +399,67 @@ select:focus-visible {
|
|||||||
}
|
}
|
||||||
.session-tab.selected .renew-session,
|
.session-tab.selected .renew-session,
|
||||||
.session-tab.selected .close-session {
|
.session-tab.selected .close-session {
|
||||||
color: #fff5e7;
|
border-left-color: #594d40;
|
||||||
|
color: #f3deca;
|
||||||
}
|
}
|
||||||
.renew-session:hover {
|
.renew-session:hover:not(:disabled) {
|
||||||
background: #294139;
|
background: #294139;
|
||||||
color: #c8ffef;
|
color: #c8ffef;
|
||||||
}
|
}
|
||||||
.close-session:hover {
|
.close-session:hover:not(:disabled) {
|
||||||
background: #4a3131;
|
background: #4a3131;
|
||||||
color: #ffd7db;
|
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 {
|
.unread-dot {
|
||||||
width: 7px;
|
width: 6px;
|
||||||
height: 7px;
|
height: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #f0a347;
|
background: #f0a347;
|
||||||
}
|
}
|
||||||
.attention-dot {
|
.attention-dot {
|
||||||
color: #ffcb6b;
|
color: #ffcb6b;
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.session-tab-actions {
|
.session-tab-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 3px;
|
align-self: stretch;
|
||||||
margin-left: auto;
|
gap: 4px;
|
||||||
padding: 0 0 2px 8px;
|
padding-left: 2px;
|
||||||
|
border-left: 1px solid #424449;
|
||||||
}
|
}
|
||||||
.session-tab-actions button {
|
.session-tab-actions button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-height: 44px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 1px solid #55585d;
|
border: 1px solid #51545a;
|
||||||
border-bottom: 0;
|
border-radius: 7px;
|
||||||
border-radius: 6px 6px 0 0;
|
padding: 0 9px;
|
||||||
padding: 5px 8px;
|
background: #303237;
|
||||||
background: #383a40;
|
color: #dde0e5;
|
||||||
color: #e2e4e8;
|
|
||||||
}
|
}
|
||||||
.session-tab-actions button:hover {
|
.session-tab-actions button:hover:not(:disabled) {
|
||||||
background: #4a4540;
|
border-color: #8a6339;
|
||||||
|
background: #3d372f;
|
||||||
color: #ffc170;
|
color: #ffc170;
|
||||||
}
|
}
|
||||||
.session-tab-actions span {
|
.session-tab-actions .new-session-tab {
|
||||||
margin-right: 5px;
|
border-color: #9a6938;
|
||||||
font-size: 15px;
|
background: #443526;
|
||||||
line-height: 0;
|
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 {
|
.directory-add {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1187,11 +1260,22 @@ dialog.extension:not([open]) {
|
|||||||
.workflow-main {
|
.workflow-main {
|
||||||
grid-template-columns: minmax(0, 1fr) 165px;
|
grid-template-columns: minmax(0, 1fr) 165px;
|
||||||
}
|
}
|
||||||
.session-strip-actions {
|
.session-tab-bar {
|
||||||
flex-direction: column;
|
grid-template-columns: 26px minmax(0, 1fr) 26px auto;
|
||||||
}
|
}
|
||||||
.session-tab > button[role="tab"] {
|
.session-scroll-button {
|
||||||
min-width: 125px;
|
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) {
|
@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");
|
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) => {
|
mocks.invoke.mockImplementation((command: string) => {
|
||||||
if (command === "create_session_runtime")
|
if (command === "create_session_runtime")
|
||||||
return Promise.resolve({ runtime: { runtimeId: "runtime-new" } });
|
return Promise.resolve({ runtime: { runtimeId: "runtime-new" } });
|
||||||
return Promise.resolve(uiState);
|
return Promise.resolve(uiState);
|
||||||
});
|
});
|
||||||
render(<App />);
|
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", {
|
const event = new KeyboardEvent("keydown", {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
@@ -156,6 +181,79 @@ describe("App command progress and errors", () => {
|
|||||||
worktreePath: "/repo",
|
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 () => {
|
it("loads categorized snapshot selectors and derives providers from choices", async () => {
|
||||||
|
|||||||
+57
-6
@@ -159,6 +159,7 @@ export default function App() {
|
|||||||
() => new Set(),
|
() => new Set(),
|
||||||
);
|
);
|
||||||
const [restoreTabFocus, setRestoreTabFocus] = useState(false);
|
const [restoreTabFocus, setRestoreTabFocus] = useState(false);
|
||||||
|
const [promptFocusRequest, setPromptFocusRequest] = useState(0);
|
||||||
const [uiState, setUiState] = useState<UiState>();
|
const [uiState, setUiState] = useState<UiState>();
|
||||||
const [quickPaneSettings, setQuickPaneSettings] = useState<QuickPaneSettings>(
|
const [quickPaneSettings, setQuickPaneSettings] = useState<QuickPaneSettings>(
|
||||||
defaultQuickPaneSettings,
|
defaultQuickPaneSettings,
|
||||||
@@ -431,6 +432,9 @@ export default function App() {
|
|||||||
dispatch({ type: "markRead", runtimeId: selectedRuntimeId }),
|
dispatch({ type: "markRead", runtimeId: selectedRuntimeId }),
|
||||||
);
|
);
|
||||||
}, [selectedRuntimeId, dispatch]);
|
}, [selectedRuntimeId, dispatch]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.windowActive) setPromptFocusRequest((current) => current + 1);
|
||||||
|
}, [state.windowActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAbortingRuntimeIds((current) => {
|
setAbortingRuntimeIds((current) => {
|
||||||
@@ -491,7 +495,10 @@ export default function App() {
|
|||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
dispatch({ type: "selectRuntime", runtimeId: result.runtime.runtimeId });
|
dispatch({ type: "selectRuntime", runtimeId: result.runtime.runtimeId });
|
||||||
await loadSnapshot(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) {
|
} catch (error) {
|
||||||
if (mountedRef.current)
|
if (mountedRef.current)
|
||||||
setStatus(`Could not start session: ${String(error)}`);
|
setStatus(`Could not start session: ${String(error)}`);
|
||||||
@@ -894,17 +901,56 @@ export default function App() {
|
|||||||
event.repeat ||
|
event.repeat ||
|
||||||
!event.ctrlKey ||
|
!event.ctrlKey ||
|
||||||
event.metaKey ||
|
event.metaKey ||
|
||||||
event.altKey ||
|
event.altKey
|
||||||
event.key.toLowerCase() !== "n" ||
|
|
||||||
sessionTabsBusy
|
|
||||||
)
|
)
|
||||||
return;
|
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();
|
event.preventDefault();
|
||||||
void createRuntime();
|
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);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
}, [createRuntime, sessionTabsBusy]);
|
}, [
|
||||||
|
activeOperations,
|
||||||
|
closeRuntime,
|
||||||
|
createRuntime,
|
||||||
|
dispatch,
|
||||||
|
runtimes,
|
||||||
|
selected,
|
||||||
|
selectedRuntimeId,
|
||||||
|
sessionTabsBusy,
|
||||||
|
]);
|
||||||
|
|
||||||
const closingRuntimeIds = new Set(
|
const closingRuntimeIds = new Set(
|
||||||
activeOperations
|
activeOperations
|
||||||
@@ -1277,6 +1323,11 @@ export default function App() {
|
|||||||
) : selected ? (
|
) : selected ? (
|
||||||
<ConversationWorkspace
|
<ConversationWorkspace
|
||||||
runtime={selected}
|
runtime={selected}
|
||||||
|
focusRequest={
|
||||||
|
adding || sessions || visibleCommandForm || selected.extensions.length
|
||||||
|
? 0
|
||||||
|
: promptFocusRequest
|
||||||
|
}
|
||||||
onDraft={(draft) =>
|
onDraft={(draft) =>
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "draftChanged",
|
type: "draftChanged",
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export function ConversationWorkspace({
|
|||||||
onScroll,
|
onScroll,
|
||||||
onModel,
|
onModel,
|
||||||
onThinking,
|
onThinking,
|
||||||
|
focusRequest = 0,
|
||||||
isSubmitting = false,
|
isSubmitting = false,
|
||||||
isAborting = false,
|
isAborting = false,
|
||||||
isModelUpdating = false,
|
isModelUpdating = false,
|
||||||
@@ -156,6 +157,7 @@ export function ConversationWorkspace({
|
|||||||
onScroll: (atBottom: boolean, scrollTop: number) => void;
|
onScroll: (atBottom: boolean, scrollTop: number) => void;
|
||||||
onModel: (model: Model) => Promise<boolean>;
|
onModel: (model: Model) => Promise<boolean>;
|
||||||
onThinking: (level: string) => Promise<boolean>;
|
onThinking: (level: string) => Promise<boolean>;
|
||||||
|
focusRequest?: number;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
isAborting?: boolean;
|
isAborting?: boolean;
|
||||||
isModelUpdating?: boolean;
|
isModelUpdating?: boolean;
|
||||||
@@ -165,6 +167,7 @@ export function ConversationWorkspace({
|
|||||||
onCommand: (command: RpcCommand) => void;
|
onCommand: (command: RpcCommand) => void;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLElement>(null);
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
|
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
|
||||||
const runtimeIsWorking =
|
const runtimeIsWorking =
|
||||||
runtime.summary.state === "streaming" || runtime.progress.phase === "working";
|
runtime.summary.state === "streaming" || runtime.progress.phase === "working";
|
||||||
@@ -215,7 +218,17 @@ export function ConversationWorkspace({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (runtime.scroll.atBottom && ref.current)
|
if (runtime.scroll.atBottom && ref.current)
|
||||||
ref.current.scrollTop = ref.current.scrollHeight;
|
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 scrollToLatest = () => {
|
||||||
const transcript = ref.current;
|
const transcript = ref.current;
|
||||||
if (!transcript) return;
|
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 && (
|
{runtime.loading && !runtime.messages.length && (
|
||||||
<p className="muted">Loading session…</p>
|
<p className="muted">Loading session…</p>
|
||||||
)}
|
)}
|
||||||
@@ -282,6 +279,22 @@ export function ConversationWorkspace({
|
|||||||
<pre>{pending.text}</pre>
|
<pre>{pending.text}</pre>
|
||||||
</article>
|
</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>
|
</section>
|
||||||
{!runtime.scroll.atBottom && (
|
{!runtime.scroll.atBottom && (
|
||||||
<button
|
<button
|
||||||
@@ -416,6 +429,7 @@ export function ConversationWorkspace({
|
|||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Prompt"
|
aria-label="Prompt"
|
||||||
|
ref={promptRef}
|
||||||
value={runtime.draft}
|
value={runtime.draft}
|
||||||
disabled={isSubmitting || locked}
|
disabled={isSubmitting || locked}
|
||||||
onChange={(event) => onDraft(event.currentTarget.value)}
|
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";
|
import type { RuntimeView } from "../workspace/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -15,6 +21,7 @@ type Props = {
|
|||||||
restoreFocus?: boolean;
|
restoreFocus?: boolean;
|
||||||
onFocusRestored?: () => void;
|
onFocusRestored?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SessionTabs({
|
export function SessionTabs({
|
||||||
runtimes,
|
runtimes,
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -30,12 +37,63 @@ export function SessionTabs({
|
|||||||
onFocusRestored,
|
onFocusRestored,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
|
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
|
const tabsRef = useRef<HTMLDivElement>(null);
|
||||||
const newSessionRef = useRef<HTMLButtonElement>(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(() => {
|
useEffect(() => {
|
||||||
if (!restoreFocus) return;
|
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?.();
|
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) {
|
function keydown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||||
let next: number | undefined;
|
let next: number | undefined;
|
||||||
if (!runtimes.length) return;
|
if (!runtimes.length) return;
|
||||||
@@ -46,13 +104,45 @@ export function SessionTabs({
|
|||||||
else if (event.key === "End") next = runtimes.length - 1;
|
else if (event.key === "End") next = runtimes.length - 1;
|
||||||
if (next === undefined) return;
|
if (next === undefined) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
refs.current[runtimes[next].summary.runtimeId]?.focus();
|
const runtimeId = runtimes[next].summary.runtimeId;
|
||||||
onSelect(runtimes[next].summary.runtimeId);
|
refs.current[runtimeId]?.focus();
|
||||||
|
reveal(runtimeId);
|
||||||
|
onSelect(runtimeId);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="session-strip">
|
<div className="session-strip">
|
||||||
<div className="session-tab-bar" aria-label="Sessions">
|
<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) => {
|
{runtimes.map((runtime, index) => {
|
||||||
const summary = runtime.summary;
|
const summary = runtime.summary;
|
||||||
const selected = summary.runtimeId === selectedId;
|
const selected = summary.runtimeId === selectedId;
|
||||||
@@ -62,6 +152,7 @@ export function SessionTabs({
|
|||||||
<div
|
<div
|
||||||
className={`session-tab ${selected ? "selected" : ""}`}
|
className={`session-tab ${selected ? "selected" : ""}`}
|
||||||
key={summary.runtimeId}
|
key={summary.runtimeId}
|
||||||
|
role="presentation"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -72,22 +163,29 @@ export function SessionTabs({
|
|||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
refs.current[summary.runtimeId] = element;
|
refs.current[summary.runtimeId] = element;
|
||||||
}}
|
}}
|
||||||
|
onFocus={() => reveal(summary.runtimeId)}
|
||||||
onKeyDown={(event) => keydown(event, index)}
|
onKeyDown={(event) => keydown(event, index)}
|
||||||
onClick={() => onSelect(summary.runtimeId)}
|
onClick={() => onSelect(summary.runtimeId)}
|
||||||
>
|
>
|
||||||
<span>{summary.label}</span>
|
<span className="session-tab-label">{summary.label}</span>
|
||||||
<small className={summary.state}>
|
<span className="session-tab-meta">
|
||||||
{summary.state}
|
<i
|
||||||
{summary.queueCount ? ` · ${summary.queueCount} queued` : ""}
|
className={`session-state-dot ${summary.state}`}
|
||||||
</small>
|
aria-hidden="true"
|
||||||
{runtime.unread && (
|
/>
|
||||||
<i className="unread-dot" aria-label="Unread activity" />
|
<small>
|
||||||
)}
|
{summary.state}
|
||||||
{summary.attention && (
|
{summary.queueCount ? ` · ${summary.queueCount} queued` : ""}
|
||||||
<b className="attention-dot" aria-label="Needs attention">
|
</small>
|
||||||
!
|
{runtime.unread && (
|
||||||
</b>
|
<i className="unread-dot" aria-label="Unread activity" />
|
||||||
)}
|
)}
|
||||||
|
{summary.attention && (
|
||||||
|
<b className="attention-dot" aria-label="Needs attention">
|
||||||
|
!
|
||||||
|
</b>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="renew-session"
|
className="renew-session"
|
||||||
@@ -127,19 +225,29 @@ export function SessionTabs({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="new-session-tab"
|
type="button"
|
||||||
ref={newSessionRef}
|
className="session-scroll-button"
|
||||||
aria-label={busy ? "Starting new session" : "New session"}
|
aria-label="Scroll sessions right"
|
||||||
title="New session"
|
title="More sessions"
|
||||||
disabled={busy}
|
disabled={!scrollPosition.right}
|
||||||
onClick={onNew}
|
onClick={() => scrollTabs(1)}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">{busy ? "…" : "+"}</span>
|
<span aria-hidden="true">›</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="session-tab-actions" aria-label="Session actions">
|
<div className="session-tab-actions" aria-label="Session actions">
|
||||||
|
<button
|
||||||
|
className="new-session-tab"
|
||||||
|
ref={newSessionRef}
|
||||||
|
aria-label={busy ? "Starting new session" : "New session"}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onNew}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{busy ? "…" : "+"}</span>
|
||||||
|
<span className="session-action-label">{busy ? "Starting" : "New"}</span>
|
||||||
|
</button>
|
||||||
<button className="load-session" disabled={busy} onClick={onResume}>
|
<button className="load-session" disabled={busy} onClick={onResume}>
|
||||||
<span aria-hidden="true">⌄</span>
|
<span aria-hidden="true">⌄</span>
|
||||||
{busy ? "Working…" : "Load"}
|
<span className="session-action-label">{busy ? "Working…" : "Load"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,7 +75,13 @@ describe("multi-session navigation", () => {
|
|||||||
expect(closeFirst.previousElementSibling).toBe(renewFirst);
|
expect(closeFirst.previousElementSibling).toBe(renewFirst);
|
||||||
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
|
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
|
||||||
const newSession = screen.getByRole("button", { name: "New session" });
|
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);
|
await userEvent.click(newSession);
|
||||||
expect(onNew).toHaveBeenCalledOnce();
|
expect(onNew).toHaveBeenCalledOnce();
|
||||||
await userEvent.click(tabs[0]);
|
await userEvent.click(tabs[0]);
|
||||||
@@ -94,6 +100,61 @@ describe("multi-session navigation", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
|
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", () => {
|
it("announces close progress only on the closing session", () => {
|
||||||
render(
|
render(
|
||||||
<SessionTabs
|
<SessionTabs
|
||||||
@@ -260,6 +321,47 @@ describe("multi-session navigation", () => {
|
|||||||
expect(modelControls.nextElementSibling).toBe(status);
|
expect(modelControls.nextElementSibling).toBe(status);
|
||||||
expect(status.nextElementSibling).toBe(composer);
|
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 () => {
|
it("shows a jump control only while scrolled up and moves to the latest message", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onScroll = vi.fn();
|
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 () => {
|
it("derives thinking choices from selected model metadata and falls back to Off", async () => {
|
||||||
const thinkingModels: Model[] = [
|
const thinkingModels: Model[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -341,10 +341,11 @@ export default function QuickPane() {
|
|||||||
!!extension;
|
!!extension;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (catalogStatus !== "ready") return;
|
||||||
setThinkingLevel((current) =>
|
setThinkingLevel((current) =>
|
||||||
reconcileThinkingLevel(selectedCatalogModel, current),
|
reconcileThinkingLevel(selectedCatalogModel, current),
|
||||||
);
|
);
|
||||||
}, [selectedCatalogModel]);
|
}, [catalogStatus, selectedCatalogModel]);
|
||||||
|
|
||||||
const recordInputSelection = (input: HTMLTextAreaElement) => {
|
const recordInputSelection = (input: HTMLTextAreaElement) => {
|
||||||
if (
|
if (
|
||||||
@@ -468,6 +469,9 @@ export default function QuickPane() {
|
|||||||
|
|
||||||
cleanupQueueRef.current.add(runtimeId);
|
cleanupQueueRef.current.add(runtimeId);
|
||||||
const cleanup = invoke("close_quick_runtime", { runtimeId })
|
const cleanup = invoke("close_quick_runtime", { runtimeId })
|
||||||
|
.catch((reason) => {
|
||||||
|
if (!String(reason).includes("unknown session runtime:")) throw reason;
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
cleanupQueueRef.current.delete(runtimeId);
|
cleanupQueueRef.current.delete(runtimeId);
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user