diff --git a/ui/src/App.css b/ui/src/App.css index d5ca057..77b87b0 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -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) { diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 7055445..d44289b 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -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(); + const prompt = screen.getByLabelText("Prompt"); + const sessionTab = screen.getByRole("tab", { name: /Session A/ }); + sessionTab.focus(); + expect(sessionTab).toHaveFocus(); + + state = { ...state, windowActive: true }; + rendered.rerender(); + + 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(); + 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(); + 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(); + 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 () => { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 3f917a4..998b98d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -159,6 +159,7 @@ export default function App() { () => new Set(), ); const [restoreTabFocus, setRestoreTabFocus] = useState(false); + const [promptFocusRequest, setPromptFocusRequest] = useState(0); const [uiState, setUiState] = useState(); const [quickPaneSettings, setQuickPaneSettings] = useState( 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(); - 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); 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 ? ( dispatch({ type: "draftChanged", diff --git a/ui/src/components/ConversationWorkspace.tsx b/ui/src/components/ConversationWorkspace.tsx index ea8791b..da2e0ab 100644 --- a/ui/src/components/ConversationWorkspace.tsx +++ b/ui/src/components/ConversationWorkspace.tsx @@ -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; onThinking: (level: string) => Promise; + focusRequest?: number; isSubmitting?: boolean; isAborting?: boolean; isModelUpdating?: boolean; @@ -165,6 +167,7 @@ export function ConversationWorkspace({ onCommand: (command: RpcCommand) => void; }) { const ref = useRef(null); + const promptRef = useRef(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 && ( -
- -
- Pi is working - - {isSubmitting ? "Sending your prompt…" : runtime.progress.detail} - - {runtime.progress.tool && Using {runtime.progress.tool}} -
-
- )} {runtime.loading && !runtime.messages.length && (

Loading session…

)} @@ -282,6 +279,22 @@ export function ConversationWorkspace({
{pending.text}
))} + {isWorking && ( +
+ +
+ Pi is working + + {isSubmitting ? "Sending your prompt…" : runtime.progress.detail} + + {runtime.progress.tool && Using {runtime.progress.tool}} +
+
+ )} {!runtime.scroll.atBottom && (