From 191c2d512781fb1ef3bd1967b9c1d28a37c42f1a Mon Sep 17 00:00:00 2001 From: touch2be Date: Thu, 15 Oct 2026 23:03:15 +0200 Subject: [PATCH 1/2] feat(chat): add transcript navigation and prompt rail Long transcripts were hard to move around in. Add: - a floating "Scroll to end" pill that appears once the user scrolls away from the bottom and turns into "New messages" when output arrives while reading further up; clicking it re-pins the transcript - a prompt rail along the left edge with one marker per user prompt, the current one highlighted, a hover preview of the prompt and the start of its reply, and click-to-jump - keyboard shortcuts: Alt+Up / Alt+Down step between prompts, Alt+End jumps to the end Co-Authored-By: Claude Opus 5.5 --- .../message-list/MessageListTranscript.tsx | 5 + .../transcript-navigation.test.tsx | 99 ++++++++ .../transcript-navigation.tsx | 213 ++++++++++++++++++ .../transcript-prompt-markers.test.ts | 39 ++++ .../transcript-prompt-markers.ts | 50 ++++ .../transcript-prompt-rail.test.tsx | 34 +++ .../transcript-prompt-rail.tsx | 100 ++++++++ .../session-transcript/transcript-rows.tsx | 2 + .../transcript-viewport.tsx | 97 +++++--- .../session-transcript/use-pinned-scroll.ts | 17 +- src/i18n/locales/de.json | 6 +- src/i18n/locales/en.json | 6 +- src/i18n/locales/es.json | 6 +- 13 files changed, 637 insertions(+), 37 deletions(-) create mode 100644 src/features/session-transcript/transcript-navigation.test.tsx create mode 100644 src/features/session-transcript/transcript-navigation.tsx create mode 100644 src/features/session-transcript/transcript-prompt-markers.test.ts create mode 100644 src/features/session-transcript/transcript-prompt-markers.ts create mode 100644 src/features/session-transcript/transcript-prompt-rail.test.tsx create mode 100644 src/features/session-transcript/transcript-prompt-rail.tsx diff --git a/src/components/message-list/MessageListTranscript.tsx b/src/components/message-list/MessageListTranscript.tsx index 6cab0409..64cc0f3f 100644 --- a/src/components/message-list/MessageListTranscript.tsx +++ b/src/components/message-list/MessageListTranscript.tsx @@ -1,6 +1,8 @@ +import { useMemo } from "react"; import { MessageListTrailing } from "@/components/message-list/MessageListTrailing"; import type { useMessageListModel } from "@/components/message-list/useMessageListModel"; import { useMessageListTranscriptRows } from "@/features/session-transcript/build-message-list-transcript-rows"; +import { buildPromptMarkers } from "@/features/session-transcript/transcript-prompt-markers"; import { TranscriptMessageRow } from "@/features/session-transcript/transcript-rows"; import { TranscriptViewport } from "@/features/session-transcript/transcript-viewport"; import { useActions } from "@/hooks/use-agent-state"; @@ -50,6 +52,8 @@ export function MessageListTranscript({ model }: { model: MessageListModel }) { revertToMessage, }); + const promptMarkers = useMemo(() => buildPromptMarkers(rows), [rows]); + const contentKey = `${activeSessionId ?? ""}:${transcriptRevision}:${visibleMessages.length}:${isBusy ? 1 : 0}`; const trailingContent = ( @@ -76,6 +80,7 @@ export function MessageListTranscript({ model }: { model: MessageListModel }) { onLoadOlder={loadOlderMessages} showLoadOlderRow={messageHistoryHasMore} trailingContent={trailingContent} + promptMarkers={promptMarkers} > {rows.map((row) => ( ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); + +import { TranscriptViewport } from "./transcript-viewport"; + +function setGeometry( + element: HTMLElement, + geometry: { scrollHeight: number; clientHeight: number; scrollTop: number }, +) { + Object.defineProperties(element, { + scrollHeight: { configurable: true, get: () => geometry.scrollHeight }, + clientHeight: { configurable: true, get: () => geometry.clientHeight }, + scrollTop: { + configurable: true, + get: () => geometry.scrollTop, + set: (value) => { + geometry.scrollTop = value; + }, + }, + }); +} + +function renderViewport(contentKey: string) { + return ( + true} + showLoadOlderRow={false} + > +
message
+
+ ); +} + +describe("TranscriptNavigation", () => { + beforeEach(() => { + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + }); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + test("offers a jump to the end once the user scrolls away and re-pins on click", () => { + render(renderViewport("1")); + const viewport = screen.getByText("message").parentElement!.parentElement!; + const button = screen.getByText("messageList.scrollToEnd").closest("button")!; + expect(button.getAttribute("aria-hidden")).toBe("true"); + + const geometry = { scrollHeight: 2000, clientHeight: 400, scrollTop: 200 }; + setGeometry(viewport, geometry); + fireEvent.scroll(viewport); + expect(button.getAttribute("aria-hidden")).toBe("false"); + + fireEvent.click(button); + expect(geometry.scrollTop).toBe(1600); + expect(button.getAttribute("aria-hidden")).toBe("true"); + }); + + test("flags new output that arrives while reading further up", () => { + const view = render(renderViewport("1")); + const viewport = screen.getByText("message").parentElement!.parentElement!; + setGeometry(viewport, { scrollHeight: 2000, clientHeight: 400, scrollTop: 200 }); + fireEvent.scroll(viewport); + expect(screen.queryByText("messageList.newMessages")).toBeNull(); + + view.rerender(renderViewport("2")); + expect(screen.getByText("messageList.newMessages")).toBeTruthy(); + }); + + test("Alt+End jumps to the end", () => { + render(renderViewport("1")); + const viewport = screen.getByText("message").parentElement!.parentElement!; + const geometry = { scrollHeight: 2000, clientHeight: 400, scrollTop: 200 }; + setGeometry(viewport, geometry); + viewport.getClientRects = () => [{}] as unknown as DOMRectList; + fireEvent.keyDown(window, { key: "End", altKey: true }); + expect(geometry.scrollTop).toBe(1600); + }); +}); diff --git a/src/features/session-transcript/transcript-navigation.tsx b/src/features/session-transcript/transcript-navigation.tsx new file mode 100644 index 00000000..b6e3db05 --- /dev/null +++ b/src/features/session-transcript/transcript-navigation.tsx @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { useTranslation } from "react-i18next"; +import { ArrowDown } from "lucide-react"; +import { isNearBottom } from "@/features/session-transcript/use-pinned-scroll"; + +/** Gap kept above a prompt after jumping to it, so it does not stick to the top edge. */ +const PROMPT_TOP_GAP_PX = 12; +const PROMPT_SELECTOR = '[data-transcript-role="user"]'; + +function promptOffset(container: HTMLElement, prompt: HTMLElement): number { + return ( + prompt.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop + ); +} + +/** The prompt above or below the current reading position, if any. */ +export function findAdjacentPrompt( + container: HTMLElement, + direction: "previous" | "next", +): HTMLElement | undefined { + const position = container.scrollTop + PROMPT_TOP_GAP_PX; + const prompts = Array.from(container.querySelectorAll(PROMPT_SELECTOR)); + if (direction === "next") { + return prompts.find((prompt) => promptOffset(container, prompt) > position + 4); + } + let match: HTMLElement | undefined; + for (const prompt of prompts) { + if (promptOffset(container, prompt) < position - 4) match = prompt; + } + return match; +} + +/** The prompt whose turn is currently being read: the last one above the upper third. */ +export function findActivePrompt(container: HTMLElement): HTMLElement | undefined { + const prompts = Array.from(container.querySelectorAll(PROMPT_SELECTOR)); + if (!isNearBottom(container) || container.scrollHeight <= container.clientHeight) { + const position = container.scrollTop + container.clientHeight / 3; + let match = prompts[0]; + for (const prompt of prompts) { + if (promptOffset(container, prompt) <= position) match = prompt; + } + return match; + } + return prompts.at(-1); +} + +/** + * Jump controls for long transcripts: back to the newest message (flagging + * output that arrived while the user was reading above), prompt-to-prompt + * stepping via Alt+↑ / Alt+↓, and direct jumps for the prompt rail. + */ +export function useTranscriptNavigation(input: { + scrollRef: RefObject; + sessionId: string | null; + contentKey: string; + scrollToBottom: () => void; + detachPin: () => void; +}) { + const { scrollRef, sessionId, contentKey, scrollToBottom, detachPin } = input; + const [awayFromBottom, setAwayFromBottom] = useState(false); + const [hasUnseen, setHasUnseen] = useState(false); + const [activePromptId, setActivePromptId] = useState(null); + const lastContentKey = useRef(contentKey); + const frame = useRef(null); + + const sync = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const away = !isNearBottom(el); + setAwayFromBottom(away); + setActivePromptId(findActivePrompt(el)?.dataset.messageId ?? null); + if (!away) setHasUnseen(false); + }, [scrollRef]); + + const onScroll = useCallback(() => { + if (frame.current !== null) return; + frame.current = requestAnimationFrame(() => { + frame.current = null; + sync(); + }); + }, [sync]); + + useEffect( + () => () => { + if (frame.current !== null) cancelAnimationFrame(frame.current); + }, + [], + ); + + useEffect(() => { + setAwayFromBottom(false); + setHasUnseen(false); + setActivePromptId(null); + }, [sessionId]); + + useEffect(() => { + const changed = lastContentKey.current !== contentKey; + lastContentKey.current = contentKey; + const el = scrollRef.current; + if (!el || !changed) return; + if (!isNearBottom(el)) { + setAwayFromBottom(true); + setHasUnseen(true); + } + sync(); + }, [contentKey, scrollRef, sync]); + + const jumpToEnd = useCallback(() => { + scrollToBottom(); + setAwayFromBottom(false); + setHasUnseen(false); + }, [scrollToBottom]); + + const alignPrompt = useCallback( + (prompt: HTMLElement) => { + const el = scrollRef.current; + if (!el) return; + detachPin(); + el.scrollTop = promptOffset(el, prompt) - PROMPT_TOP_GAP_PX; + // Rows use content-visibility, so heights above the target can settle after the jump. + requestAnimationFrame(() => { + el.scrollTop = promptOffset(el, prompt) - PROMPT_TOP_GAP_PX; + sync(); + }); + }, + [scrollRef, detachPin, sync], + ); + + const jumpToPrompt = useCallback( + (direction: "previous" | "next") => { + const el = scrollRef.current; + if (!el) return; + const prompt = findAdjacentPrompt(el, direction); + if (prompt) alignPrompt(prompt); + else if (direction === "next") jumpToEnd(); + else if (el.scrollTop > 0) { + detachPin(); + el.scrollTop = 0; + } + }, + [scrollRef, alignPrompt, detachPin, jumpToEnd], + ); + + const jumpToMessage = useCallback( + (messageId: string) => { + const prompt = scrollRef.current?.querySelector( + `[data-message-id="${CSS.escape(messageId)}"]`, + ); + if (prompt) alignPrompt(prompt); + }, + [scrollRef, alignPrompt], + ); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || !event.altKey || event.ctrlKey || event.metaKey) return; + const el = scrollRef.current; + if (!el || !el.isConnected || el.getClientRects().length === 0) return; + if (event.key === "ArrowUp") jumpToPrompt("previous"); + else if (event.key === "ArrowDown") jumpToPrompt("next"); + else if (event.key === "End") jumpToEnd(); + else return; + event.preventDefault(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [scrollRef, jumpToPrompt, jumpToEnd]); + + return { + onScroll, + awayFromBottom, + hasUnseen, + activePromptId, + jumpToEnd, + jumpToPrompt, + jumpToMessage, + }; +} + +export function TranscriptNavigation({ + awayFromBottom, + hasUnseen, + onJumpToEnd, +}: { + awayFromBottom: boolean; + hasUnseen: boolean; + onJumpToEnd: () => void; +}) { + const { t } = useTranslation(); + + return ( +
+
+ +
+
+ ); +} diff --git a/src/features/session-transcript/transcript-prompt-markers.test.ts b/src/features/session-transcript/transcript-prompt-markers.test.ts new file mode 100644 index 00000000..ce0374ff --- /dev/null +++ b/src/features/session-transcript/transcript-prompt-markers.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vite-plus/test"; +import { buildPromptMarkers } from "./transcript-prompt-markers"; +import type { TranscriptRow } from "./transcript-row-model"; + +function row(id: string, role: string, ...texts: string[]): TranscriptRow { + return { + kind: "message", + id, + spacing: "", + actions: {}, + entry: { + info: { id, sessionID: "s1", role, time: { created: 1 } }, + parts: texts.map((text, index) => ({ id: `${id}-${index}`, type: "text", text })), + }, + } as TranscriptRow; +} + +describe("buildPromptMarkers", () => { + test("pairs each prompt with the reply text that followed it", () => { + const markers = buildPromptMarkers([ + row("u1", "user", "Fix the login\nbug"), + row("a1", "assistant", "Done, the session", "cookie is set."), + row("u2", "user", "Look at @shots/home.png please"), + ]); + expect(markers).toEqual([ + { id: "u1", prompt: "Fix the login bug", reply: "Done, the session cookie is set." }, + { id: "u2", prompt: "Look at home.png please", reply: "" }, + ]); + }); + + test("ignores synthetic parts and clips long previews", () => { + const first = row("u1", "user", "x".repeat(400)); + first.entry.parts.push({ id: "syn", type: "text", text: "hidden", synthetic: true }); + const [marker] = buildPromptMarkers([first]); + expect(marker?.prompt).toHaveLength(280); + expect(marker?.prompt.endsWith("…")).toBe(true); + expect(marker?.prompt).not.toContain("hidden"); + }); +}); diff --git a/src/features/session-transcript/transcript-prompt-markers.ts b/src/features/session-transcript/transcript-prompt-markers.ts new file mode 100644 index 00000000..d2bec17c --- /dev/null +++ b/src/features/session-transcript/transcript-prompt-markers.ts @@ -0,0 +1,50 @@ +import type { TranscriptRow } from "@/features/session-transcript/transcript-row-model"; +import { splitImageMentions } from "@/lib/image-mentions"; + +const PREVIEW_MAX_CHARS = 280; + +export type PromptMarker = { + /** Message id of the user prompt; matches the row's `data-message-id`. */ + id: string; + prompt: string; + reply: string; +}; + +function visibleText(row: TranscriptRow): string { + const text = row.entry.parts + .map((part) => (part.type === "text" && !part.synthetic && !part.ignored ? part.text : "")) + .join(" "); + return splitImageMentions(text) + .map((segment) => (segment.type === "text" ? segment.text : segment.filename)) + .join("") + .replace(/\s+/g, " ") + .trim(); +} + +function clip(text: string): string { + return text.length > PREVIEW_MAX_CHARS ? `${text.slice(0, PREVIEW_MAX_CHARS - 1)}…` : text; +} + +/** One marker per user prompt, paired with the text of the reply that followed it. */ +export function buildPromptMarkers(rows: readonly TranscriptRow[]): PromptMarker[] { + const markers: PromptMarker[] = []; + let replyParts: string[] = []; + + const closeReply = () => { + const current = markers.at(-1); + if (current) current.reply = clip(replyParts.join(" ").trim()); + replyParts = []; + }; + + for (const row of rows) { + if (row.entry.info.role === "user") { + closeReply(); + markers.push({ id: row.id, prompt: clip(visibleText(row)), reply: "" }); + } else if (markers.length > 0 && replyParts.join(" ").length < PREVIEW_MAX_CHARS) { + const text = visibleText(row); + if (text) replyParts.push(text); + } + } + closeReply(); + return markers; +} diff --git a/src/features/session-transcript/transcript-prompt-rail.test.tsx b/src/features/session-transcript/transcript-prompt-rail.test.tsx new file mode 100644 index 00000000..32128518 --- /dev/null +++ b/src/features/session-transcript/transcript-prompt-rail.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +import { TranscriptPromptRail } from "./transcript-prompt-rail"; + +const markers = [ + { id: "u1", prompt: "First prompt", reply: "First reply" }, + { id: "u2", prompt: "Second prompt", reply: "" }, +]; + +describe("TranscriptPromptRail", () => { + afterEach(cleanup); + + test("marks the current prompt, previews on hover and jumps on click", () => { + const onSelect = vi.fn(); + render(); + + const second = screen.getByRole("button", { name: "Second prompt" }); + expect(second.getAttribute("aria-current")).toBe("location"); + + const first = screen.getByRole("button", { name: "First prompt" }); + fireEvent.mouseEnter(first); + expect(screen.getByRole("tooltip").textContent).toContain("First reply"); + + fireEvent.click(first); + expect(onSelect).toHaveBeenCalledWith("u1"); + }); +}); diff --git a/src/features/session-transcript/transcript-prompt-rail.tsx b/src/features/session-transcript/transcript-prompt-rail.tsx new file mode 100644 index 00000000..238ece3d --- /dev/null +++ b/src/features/session-transcript/transcript-prompt-rail.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { PromptMarker } from "@/features/session-transcript/transcript-prompt-markers"; + +/** + * Minimap of the user's prompts along the left edge of the transcript: one line + * per prompt, the current one highlighted, with a preview card on hover. + */ +export function TranscriptPromptRail({ + markers, + activeId, + onSelect, +}: { + markers: readonly PromptMarker[]; + activeId: string | null; + onSelect: (id: string) => void; +}) { + const { t } = useTranslation(); + const navRef = useRef(null); + const listRef = useRef(null); + const [preview, setPreview] = useState<{ marker: PromptMarker; top: number } | null>(null); + + useEffect(() => { + const list = listRef.current; + const active = activeId + ? list?.querySelector(`[data-marker-id="${CSS.escape(activeId)}"]`) + : null; + if (!list || !active) return; + if (active.offsetTop < list.scrollTop) list.scrollTop = active.offsetTop; + else if (active.offsetTop + active.offsetHeight > list.scrollTop + list.clientHeight) { + list.scrollTop = active.offsetTop + active.offsetHeight - list.clientHeight; + } + }, [activeId]); + + const showPreview = (marker: PromptMarker, target: HTMLElement) => { + const nav = navRef.current; + if (!nav) return; + const rect = target.getBoundingClientRect(); + setPreview({ marker, top: rect.top + rect.height / 2 - nav.getBoundingClientRect().top }); + }; + + return ( + + ); +} diff --git a/src/features/session-transcript/transcript-rows.tsx b/src/features/session-transcript/transcript-rows.tsx index 6b3dd019..d50159cf 100644 --- a/src/features/session-transcript/transcript-rows.tsx +++ b/src/features/session-transcript/transcript-rows.tsx @@ -24,6 +24,8 @@ export const TranscriptMessageRow = memo(function TranscriptMessageRow({ className={row.spacing} style={{ contentVisibility: "auto" }} data-created-at={row.entry.info.time.created || undefined} + data-transcript-role={row.entry.info.role} + data-message-id={row.id} > { detachPin(); @@ -45,42 +60,58 @@ export function TranscriptViewport({ void onLoadOlder(); }; + const showPromptRail = promptMarkers.length > 1; + return ( -
{ - onScroll(); - timeIndicator.onScroll(); - }} - onWheel={(event) => { - if (event.deltaY < 0) detachPin(); - }} - className="relative flex-1 overflow-y-auto overflow-x-hidden bg-background px-4 py-4 [overflow-anchor:none]" - > - +
{ + onScroll(); + timeIndicator.onScroll(); + navigation.onScroll(); + }} + onWheel={(event) => { + if (event.deltaY < 0) detachPin(); + }} + className={`relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden bg-background py-4 pr-4 [overflow-anchor:none] ${showPromptRail ? "pl-8" : "pl-4"}`} > - {showLoadOlderRow && ( -
- {isLoadingOlder ? ( - - ) : ( - - )} - {loadOlderError && !isLoadingOlder && ( -
- {t("messageList.loadOlderFailed")} -
- )} -
- )} - {children} - {trailingContent} + +
+ {showLoadOlderRow && ( +
+ {isLoadingOlder ? ( + + ) : ( + + )} + {loadOlderError && !isLoadingOlder && ( +
+ {t("messageList.loadOlderFailed")} +
+ )} +
+ )} + {children} + {trailingContent} + +
+ {showPromptRail && ( + + )}
); } diff --git a/src/features/session-transcript/use-pinned-scroll.ts b/src/features/session-transcript/use-pinned-scroll.ts index 5154542b..cd4f7241 100644 --- a/src/features/session-transcript/use-pinned-scroll.ts +++ b/src/features/session-transcript/use-pinned-scroll.ts @@ -5,7 +5,7 @@ function distanceFromBottom(element: HTMLElement): number { return Math.max(0, element.scrollHeight - element.scrollTop - element.clientHeight); } -function isNearBottom(element: HTMLElement): boolean { +export function isNearBottom(element: HTMLElement): boolean { const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight); if (maxScrollTop <= NEAR_BOTTOM_PX) return true; return distanceFromBottom(element) <= NEAR_BOTTOM_PX; @@ -56,6 +56,20 @@ export function usePinnedScroll(input: { }; }, []); + const scrollToBottom = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + pinnedRef.current = true; + markProgrammatic(); + pinToBottom(el); + // Rows use content-visibility, so their real height can settle after the jump. + requestAnimationFrame(() => { + if (!pinnedRef.current) return; + markProgrammatic(); + pinToBottom(el); + }); + }, [markProgrammatic]); + useLayoutEffect(() => { pinnedRef.current = true; prependAnchorRef.current = null; @@ -103,6 +117,7 @@ export function usePinnedScroll(input: { onScroll, detachPin, capturePrependAnchor, + scrollToBottom, isPinned: () => pinnedRef.current, }; } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6d09c149..f7da7e24 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -122,7 +122,11 @@ }, "messageList": { "loadOlder": "Ältere Nachrichten laden", - "loadOlderFailed": "Ältere Nachrichten konnten nicht geladen werden. Versuche es erneut." + "loadOlderFailed": "Ältere Nachrichten konnten nicht geladen werden. Versuche es erneut.", + "scrollToEnd": "Zum Ende", + "newMessages": "Neue Nachrichten", + "promptNavigation": "Prompt-Navigation", + "promptNumber": "Prompt {{number}}" }, "toolGroup": { "runningCommands_one": "führe einen Befehl aus", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 70fb8921..4f1527a2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -137,7 +137,11 @@ }, "messageList": { "loadOlder": "Load older messages", - "loadOlderFailed": "Could not load older messages. Try again." + "loadOlderFailed": "Could not load older messages. Try again.", + "scrollToEnd": "Scroll to end", + "newMessages": "New messages", + "promptNavigation": "Prompt navigation", + "promptNumber": "Prompt {{number}}" }, "toolGroup": { "runningCommands_one": "running one command", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 14a8393a..f52b952c 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -122,7 +122,11 @@ }, "messageList": { "loadOlder": "Cargar mensajes anteriores", - "loadOlderFailed": "No se pudieron cargar los mensajes anteriores. Inténtalo de nuevo." + "loadOlderFailed": "No se pudieron cargar los mensajes anteriores. Inténtalo de nuevo.", + "scrollToEnd": "Ir al final", + "newMessages": "Mensajes nuevos", + "promptNavigation": "Navegación de prompts", + "promptNumber": "Prompt {{number}}" }, "toolGroup": { "runningCommands_one": "ejecutando un comando", From 7c776c5231d3811c34bf4fa68c25aa40585ec01e Mon Sep 17 00:00:00 2001 From: touch2be Date: Thu, 15 Oct 2026 23:03:20 +0200 Subject: [PATCH 2/2] feat(sidebar): hide fully and resize by dragging - collapsing now hides the sidebar entirely (offcanvas) instead of shrinking it to an icon strip; the toggle sits next to the logo in the sidebar header and the title-bar toggle only shows while the sidebar is hidden (or on the mobile drawer layout) - drop the stray `relative` class on AppSidebar that overrode the container's `fixed` positioning and kept the space reserved when hidden - drag the sidebar's inner edge to resize (200-480px, persisted); double-click resets, and the handle is a focusable separator that also responds to arrow keys and Home/End - left-align the logo next to the toggle so it stays put while resizing Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 8 +- src/components/AppSidebar.tsx | 2 +- .../sidebar/SidebarHeaderContent.tsx | 25 ++++- src/components/ui/sidebar-resize.test.tsx | 45 +++++++++ src/components/ui/sidebar.tsx | 94 ++++++++++++++++++- src/hooks/use-sidebar-width.ts | 41 ++++++++ src/i18n/locales/de.json | 4 +- src/i18n/locales/en.json | 4 +- src/i18n/locales/es.json | 4 +- src/lib/constants.ts | 1 + 10 files changed, 216 insertions(+), 12 deletions(-) create mode 100644 src/components/ui/sidebar-resize.test.tsx create mode 100644 src/hooks/use-sidebar-width.ts diff --git a/src/App.tsx b/src/App.tsx index 970cc738..6a5b55cf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -202,7 +202,13 @@ export function AppContent({ />
- +
{isBooting && ( diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 89ab1e59..c6a349af 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -426,7 +426,7 @@ export function AppSidebar({ }); return ( - + {profileOpen && setProfileOpen(false)} />}
{selectionActions ?? ( <>
+ {!detachedProject && ( + + )} OpenGUI ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false })); + +import { Sidebar, SidebarProvider } from "./sidebar"; +import { clampSidebarWidth, SIDEBAR_DEFAULT_WIDTH_PX } from "@/hooks/use-sidebar-width"; +import { STORAGE_KEYS } from "@/lib/constants"; +import { storageSet } from "@/lib/persistence/storage"; + +function renderSidebar() { + return render( + + content + , + ); +} + +describe("sidebar resizing", () => { + afterEach(() => { + cleanup(); + storageSet(STORAGE_KEYS.SIDEBAR_WIDTH, String(SIDEBAR_DEFAULT_WIDTH_PX)); + }); + + test("clamps widths to the supported range", () => { + expect(clampSidebarWidth(50)).toBe(200); + expect(clampSidebarWidth(9999)).toBe(480); + expect(clampSidebarWidth(Number.NaN)).toBe(SIDEBAR_DEFAULT_WIDTH_PX); + }); + + test("resizes with the keyboard and resets on double-click", () => { + const view = renderSidebar(); + const wrapper = view.container.querySelector("[data-slot=sidebar-wrapper]")!; + const handle = screen.getByRole("separator", { name: "workspace.resizeSidebar" }); + + fireEvent.keyDown(handle, { key: "ArrowRight" }); + expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("272px"); + + fireEvent.doubleClick(handle); + expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe(`${SIDEBAR_DEFAULT_WIDTH_PX}px`); + }); +}); diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 09878042..2f105b43 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -17,6 +17,13 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useSidebarController } from "./use-sidebar-controller"; +import { + SIDEBAR_DEFAULT_WIDTH_PX, + SIDEBAR_MAX_WIDTH_PX, + SIDEBAR_MIN_WIDTH_PX, + clampSidebarWidth, + useSidebarWidth, +} from "@/hooks/use-sidebar-width"; function Slot({ children, ...props }: { children?: React.ReactNode; [key: string]: unknown }) { if (React.isValidElement(children)) { @@ -27,7 +34,6 @@ function Slot({ children, ...props }: { children?: React.ReactNode; [key: string Slot.Root = Slot as React.ElementType; -const SIDEBAR_WIDTH = "16rem"; const SIDEBAR_WIDTH_MOBILE = "18rem"; const SIDEBAR_WIDTH_ICON = "3rem"; @@ -75,6 +81,7 @@ function SidebarProvider({ // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. const state = open ? "expanded" : "collapsed"; + const [sidebarWidth] = useSidebarWidth(); const contextValue = React.useMemo( () => ({ @@ -96,7 +103,7 @@ function SidebarProvider({ data-slot="sidebar-wrapper" style={ { - "--sidebar-width": SIDEBAR_WIDTH, + "--sidebar-width": `${sidebarWidth}px`, "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, ...style, } as React.CSSProperties @@ -183,7 +190,7 @@ function Sidebar({ + {side === "left" && }
); } +const SIDEBAR_RESIZE_KEYBOARD_STEP_PX = 16; + +/** Drag handle on the sidebar's inner edge; double-click restores the default width. */ +function SidebarResizeHandle() { + const { t } = useTranslation(); + const { state } = useSidebar(); + const [width, setWidth] = useSidebarWidth(); + const dragRef = React.useRef<{ startX: number; startWidth: number; wrapper: HTMLElement } | null>( + null, + ); + + if (state === "collapsed") return null; + + const endDrag = (event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + dragRef.current = null; + delete drag.wrapper.dataset.sidebarResizing; + document.body.style.removeProperty("cursor"); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + setWidth(drag.startWidth + event.clientX - drag.startX); + }; + + return ( + // biome-ignore lint/a11y/useSemanticElements: A focusable separator is the ARIA pattern for resizable panes. +
{ + if (event.button !== 0) return; + const wrapper = event.currentTarget.closest("[data-slot=sidebar-wrapper]"); + if (!wrapper) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + event.currentTarget.dataset.dragging = "true"; + wrapper.dataset.sidebarResizing = "true"; + document.body.style.cursor = "col-resize"; + dragRef.current = { startX: event.clientX, startWidth: width, wrapper }; + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag) return; + const next = clampSidebarWidth(drag.startWidth + event.clientX - drag.startX); + // Live-update the CSS variable; the setting is persisted once on release. + drag.wrapper.style.setProperty("--sidebar-width", `${next}px`); + }} + onPointerUp={(event) => { + delete event.currentTarget.dataset.dragging; + endDrag(event); + }} + onPointerCancel={(event) => { + delete event.currentTarget.dataset.dragging; + endDrag(event); + }} + onDoubleClick={() => setWidth(SIDEBAR_DEFAULT_WIDTH_PX)} + onKeyDown={(event) => { + if (event.key === "ArrowLeft") setWidth(width - SIDEBAR_RESIZE_KEYBOARD_STEP_PX); + else if (event.key === "ArrowRight") setWidth(width + SIDEBAR_RESIZE_KEYBOARD_STEP_PX); + else if (event.key === "Home") setWidth(SIDEBAR_MIN_WIDTH_PX); + else if (event.key === "End") setWidth(SIDEBAR_MAX_WIDTH_PX); + else return; + event.preventDefault(); + }} + /> + ); +} + function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { const { t } = useTranslation(); const { toggleSidebar } = useSidebar(); diff --git a/src/hooks/use-sidebar-width.ts b/src/hooks/use-sidebar-width.ts new file mode 100644 index 00000000..6adbd6f8 --- /dev/null +++ b/src/hooks/use-sidebar-width.ts @@ -0,0 +1,41 @@ +import { useSyncExternalStore } from "react"; +import { STORAGE_KEYS } from "@/lib/constants"; +import { onSettingsChange, storageGet, storageSet } from "@/lib/persistence/storage"; + +export const SIDEBAR_DEFAULT_WIDTH_PX = 256; +export const SIDEBAR_MIN_WIDTH_PX = 200; +export const SIDEBAR_MAX_WIDTH_PX = 480; + +export function clampSidebarWidth(width: number): number { + if (!Number.isFinite(width)) return SIDEBAR_DEFAULT_WIDTH_PX; + return Math.round(Math.min(SIDEBAR_MAX_WIDTH_PX, Math.max(SIDEBAR_MIN_WIDTH_PX, width))); +} + +function getSidebarWidth(): number { + const stored = storageGet(STORAGE_KEYS.SIDEBAR_WIDTH); + return stored ? clampSidebarWidth(Number(stored)) : SIDEBAR_DEFAULT_WIDTH_PX; +} + +function subscribe(callback: () => void): () => void { + const unsubscribe = onSettingsChange(({ key }) => { + if (key === STORAGE_KEYS.SIDEBAR_WIDTH) callback(); + }); + // Browser storage events from other tabs are not dispatched via onSettingsChange. + const onStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEYS.SIDEBAR_WIDTH) callback(); + }; + window.addEventListener("storage", onStorage); + return () => { + unsubscribe(); + window.removeEventListener("storage", onStorage); + }; +} + +/** Desktop sidebar width in px, persisted across restarts. */ +export function useSidebarWidth(): [number, (value: number) => void] { + const width = useSyncExternalStore(subscribe, getSidebarWidth, () => SIDEBAR_DEFAULT_WIDTH_PX); + return [ + width, + (value) => storageSet(STORAGE_KEYS.SIDEBAR_WIDTH, String(clampSidebarWidth(value))), + ]; +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index f7da7e24..3e45b035 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -443,7 +443,9 @@ "createAccountAndAdd": "Konto erstellen und Workspace hinzufügen", "addWorkspace": "Workspace hinzufügen", "toggleSidebar": "Sidebar umschalten", - "requiredBeforeProject": "Füge zuerst einen Workspace hinzu, bevor du ein Projekt verbindest." + "requiredBeforeProject": "Füge zuerst einen Workspace hinzu, bevor du ein Projekt verbindest.", + "hideSidebar": "Sidebar ausblenden", + "resizeSidebar": "Ziehen, um die Sidebar-Breite zu ändern" }, "identity": { "checkingHost": "Host-Identität wird geprüft", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4f1527a2..8820ddb7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -443,7 +443,9 @@ "createAccountAndAdd": "Create account & add workspace", "addWorkspace": "Add workspace", "toggleSidebar": "Toggle Sidebar", - "requiredBeforeProject": "Add a workspace before connecting a project." + "requiredBeforeProject": "Add a workspace before connecting a project.", + "hideSidebar": "Hide sidebar", + "resizeSidebar": "Drag to resize sidebar" }, "identity": { "checkingHost": "Checking Host identity", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f52b952c..50859aa7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -443,7 +443,9 @@ "createAccountAndAdd": "Crear cuenta y añadir", "addWorkspace": "Añadir espacio de trabajo", "toggleSidebar": "Alternar barra lateral", - "requiredBeforeProject": "Añade un espacio de trabajo antes de conectar un proyecto." + "requiredBeforeProject": "Añade un espacio de trabajo antes de conectar un proyecto.", + "hideSidebar": "Ocultar barra lateral", + "resizeSidebar": "Arrastra para cambiar el ancho de la barra lateral" }, "identity": { "checkingHost": "Comprobando la identidad del Host", diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e02f25df..5512f309 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -33,6 +33,7 @@ export const STORAGE_KEYS = { LANGUAGE: key("language"), THEME: "theme", CHAT_WIDTH: key("chatWidth"), + SIDEBAR_WIDTH: key("sidebarWidth"), CONTRAST: key("contrast"), ACCENT_COLOR: key("accentColor"), DISMISSED_UPDATE_VERSION: key("dismissedUpdateVersion"),