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.
This commit is contained in:
+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();
|
||||||
|
|||||||
Reference in New Issue
Block a user