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;
|
||||
}
|
||||
.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();
|
||||
|
||||
Reference in New Issue
Block a user