From 9622305befe95ccac2e4767f8b22e6d2a722983c Mon Sep 17 00:00:00 2001 From: Tobias Preiss Date: Thu, 22 Oct 2026 20:37:06 +0200 Subject: [PATCH 1/3] feat: chat navigation, prompt rail, and resizable/hideable sidebar (#167) * 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 * 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 --------- Co-authored-by: touch2be Co-authored-by: Claude Opus 5.5 --- src/App.tsx | 8 +- src/components/AppSidebar.tsx | 2 +- .../message-list/MessageListTranscript.tsx | 5 + .../sidebar/SidebarHeaderContent.tsx | 25 +- src/components/ui/sidebar-resize.test.tsx | 45 ++++ src/components/ui/sidebar.tsx | 94 +++++++- .../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/hooks/use-sidebar-width.ts | 41 ++++ src/i18n/locales/de.json | 10 +- src/i18n/locales/en.json | 10 +- src/i18n/locales/es.json | 10 +- src/lib/constants.ts | 1 + 20 files changed, 853 insertions(+), 49 deletions(-) create mode 100644 src/components/ui/sidebar-resize.test.tsx 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 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)} />}
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) => ( {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/features/session-transcript/transcript-navigation.test.tsx b/src/features/session-transcript/transcript-navigation.test.tsx new file mode 100644 index 00000000..6cf73a5e --- /dev/null +++ b/src/features/session-transcript/transcript-navigation.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test"; + +vi.mock("react-i18next", () => ({ + 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/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 e1739702..6d584f78 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", @@ -439,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 0364c2d9..8c8a99ae 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", @@ -439,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 065df2ee..f552b6cf 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", @@ -439,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"), From eff97d7a1028176ea42ea1b10781bf00ebd5cb17 Mon Sep 17 00:00:00 2001 From: Tobias Preiss Date: Thu, 22 Oct 2026 20:37:15 +0200 Subject: [PATCH 2/3] feat(settings): centralize preferences and add quick appearance controls (#168) Co-authored-by: touch2be --- src/App.render.test.tsx | 1 + src/App.tsx | 11 ++- src/components/AppSidebar.tsx | 7 +- .../settings/SettingsView.render.test.tsx | 40 ++++++++- src/components/settings/SettingsView.tsx | 53 +++++++++-- .../sidebar/SidebarFooterContent.tsx | 69 +++++++------- src/features/profile/FontSizeSetting.tsx | 27 ++++++ src/features/profile/PreferenceControls.tsx | 40 ++++----- src/features/profile/PreferenceSections.tsx | 34 +++++++ .../profile/ProfilePanel.render.test.tsx | 19 ++-- src/features/profile/ProfilePanel.tsx | 89 ++----------------- src/features/profile/QuickPreferences.tsx | 89 +++++++++++++++++++ .../transcript-viewport.test.tsx | 7 +- .../transcript-viewport.tsx | 3 +- src/frontend.tsx | 2 + src/hooks/use-chat-width.test.tsx | 24 +++-- src/hooks/use-chat-width.ts | 20 +++-- src/hooks/use-font-size.test.tsx | 25 ++++++ src/hooks/use-font-size.ts | 41 +++++++++ src/hooks/use-theme.test.tsx | 12 +++ src/hooks/use-theme.ts | 20 ++++- src/i18n/locales/de.json | 22 +++-- src/i18n/locales/en.json | 22 +++-- src/i18n/locales/es.json | 22 +++-- src/lib/constants.ts | 1 + 25 files changed, 509 insertions(+), 191 deletions(-) create mode 100644 src/features/profile/FontSizeSetting.tsx create mode 100644 src/features/profile/PreferenceSections.tsx create mode 100644 src/features/profile/QuickPreferences.tsx create mode 100644 src/hooks/use-font-size.test.tsx create mode 100644 src/hooks/use-font-size.ts diff --git a/src/App.render.test.tsx b/src/App.render.test.tsx index b34d5047..6d324a6e 100644 --- a/src/App.render.test.tsx +++ b/src/App.render.test.tsx @@ -134,6 +134,7 @@ describe("App shell orchestration", () => { render(); await userEvent.click(screen.getByText("open settings")); expect(await screen.findByText("settings view")).toBeTruthy(); + expect(screen.getByText("open settings")).toBeTruthy(); expect(fixture.dismissSetup).toHaveBeenCalled(); await userEvent.click(screen.getByText("back")); expect(screen.getByText("transcript")).toBeTruthy(); diff --git a/src/App.tsx b/src/App.tsx index 6a5b55cf..ddd2a07e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import type { SectionId } from "@/components/settings/SettingsView"; import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { QueueList } from "@/components/QueueList"; @@ -63,6 +64,7 @@ export function AppContent({ }) { const { t } = useTranslation(); const [chatWidth] = useChatWidth(); + const [settingsTab, setSettingsTab] = useState(); const identityActor = useIdentityActor(); const [activeView, setActiveView] = useState<"chat" | "settings">("chat"); const leftSidebar = useSidebar(); @@ -181,6 +183,7 @@ export function AppContent({ useEffect(() => { const openSettings = () => { + setSettingsTab(undefined); onDismissSetup?.(); setActiveView("settings"); }; @@ -193,7 +196,8 @@ export function AppContent({ { + onOpenSettings={(tab) => { + setSettingsTab(tab); onDismissSetup?.(); setActiveView("settings"); }} @@ -231,7 +235,7 @@ export function AppContent({
} > - setActiveView("chat")} /> + setActiveView("chat")} /> ) : ( <> @@ -257,7 +261,8 @@ export function AppContent({ {showPromptBox && !(workspaces.length === 0 && supportsMultipleWorkspaces) && (
{queuedPrompts.length > 0 && (
diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index c6a349af..56f6311e 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,3 +1,4 @@ +import type { SectionId } from "@/components/settings/SettingsView"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Plus, X } from "lucide-react"; @@ -49,7 +50,7 @@ export function AppSidebar({ }: { detachedProject?: string; highlightedSessionId?: string | null; - onOpenSettings: () => void; + onOpenSettings: (tab?: SectionId) => void; onOpenChat: () => void; settingsActive?: boolean; }) { @@ -661,9 +662,9 @@ export function AppSidebar({ activeSessionDirectory={activeSessionDirectory} homeDir={homeDir} settingsActive={settingsActive} - onOpenSettings={() => { + onOpenSettings={(tab) => { setProfileOpen(false); - onOpenSettings(); + onOpenSettings(tab); closeMobileSidebar(); }} profileOpen={profileOpen} diff --git a/src/components/settings/SettingsView.render.test.tsx b/src/components/settings/SettingsView.render.test.tsx index 984fc368..086f9a67 100644 --- a/src/components/settings/SettingsView.render.test.tsx +++ b/src/components/settings/SettingsView.render.test.tsx @@ -26,6 +26,7 @@ vi.mock("@/features/skills/SkillsSettings", () => ({ })); vi.mock("@/features/instructions/InstructionsSettings", () => ({ InstructionsSettings: () =>

instructions-panel

, + usePersonalInstructionsAvailable: () => false, })); vi.mock("@/features/mcp/McpSettings", () => ({ McpSettings: () =>

mcp-connections

, @@ -38,6 +39,14 @@ vi.mock("@/features/identity/workspace-identity", () => ({ identityWorkspaceIsLocalBypass: () => false, })); +vi.mock("@/features/profile/PreferenceSections", () => ({ + AppearanceSettings: () =>

appearance-controls

, + ChatPreferences: () =>

chat-controls

, +})); +vi.mock("@/features/profile/PreferenceControls", () => ({ + LanguageOptions: () =>

language-options

, +})); + import { SettingsView } from "./SettingsView"; describe("SettingsView responsive navigation", () => { @@ -47,14 +56,32 @@ describe("SettingsView responsive navigation", () => { render(); const navigation = screen.getByRole("navigation", { name: "settings.navigationLabel" }); expect(navigation).toBeTruthy(); - for (const tab of ["models", "integrations", "paths", "host", "desktop"]) { + for (const tab of [ + "appearance", + "language", + "chats", + "models", + "integrations", + "paths", + "host", + "desktop", + ]) { expect(screen.getByRole("button", { name: `settings.tabs.${tab}` })).toBeTruthy(); } expect(screen.getAllByText("settings.groups.host").length).toBe(1); expect(screen.queryByText("settings.tabs.general")).toBeNull(); await userEvent.click(screen.getByRole("combobox", { name: "settings.navigationLabel" })); - for (const tab of ["models", "integrations", "paths", "host", "desktop"]) { + for (const tab of [ + "appearance", + "language", + "chats", + "models", + "integrations", + "paths", + "host", + "desktop", + ]) { expect(await screen.findByRole("option", { name: `settings.tabs.${tab}` })).toBeTruthy(); } await userEvent.click(screen.getByRole("option", { name: "settings.tabs.paths" })); @@ -77,6 +104,15 @@ describe("SettingsView responsive navigation", () => { expect(screen.getByText("instructions-panel")).toBeTruthy(); }); + test("opens personal preferences and supports the appearance shortcut", async () => { + render(); + expect(screen.getByText("appearance-controls")).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "settings.tabs.language" })); + expect(screen.getByText("language-options")).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "settings.tabs.chats" })); + expect(screen.getByText("chat-controls")).toBeTruthy(); + }); + test("opens on the first available section", () => { render(); expect(screen.getByText("models-panel")).toBeTruthy(); diff --git a/src/components/settings/SettingsView.tsx b/src/components/settings/SettingsView.tsx index 30a2833c..3f390608 100644 --- a/src/components/settings/SettingsView.tsx +++ b/src/components/settings/SettingsView.tsx @@ -3,6 +3,9 @@ import { Bot, FolderKey, Laptop, + Palette, + Languages, + MessageSquare, Plug, ScrollText, Settings2, @@ -38,7 +41,11 @@ import { import { NamedTeamsSettings } from "@/features/identity/NamedTeamsSettings"; import { TeamSettings } from "@/features/identity/TeamSettings"; import { SkillsSettings } from "@/features/skills/SkillsSettings"; -import { InstructionsSettings } from "@/features/instructions/InstructionsSettings"; +import { + PersonalInstructions, + usePersonalInstructionsAvailable, + InstructionsSettings, +} from "@/features/instructions/InstructionsSettings"; import { McpSettings } from "@/features/mcp/McpSettings"; import { useIdentityActor } from "@/features/identity/identity-actor-context"; import { ownerSettingsVisibility } from "@/features/identity/identity-state"; @@ -50,7 +57,10 @@ import { cn } from "@/lib/utils"; import { getDesktopShellClient } from "@/runtime/clients"; import packageJson from "../../../package.json"; -type SectionId = +export type SectionId = + | "appearance" + | "language" + | "chats" | "models" | "integrations" | "skills" @@ -61,12 +71,22 @@ type SectionId = | "host" | "desktop"; +import { AppearanceSettings, ChatPreferences } from "@/features/profile/PreferenceSections"; +import { LanguageOptions } from "@/features/profile/PreferenceControls"; + type Section = { id: SectionId; icon: LucideIcon; visible: boolean }; -type Group = { id: "agent" | "host" | "device"; sections: Section[] }; +type Group = { id: "personal" | "agent" | "host" | "device"; sections: Section[] }; -export function SettingsView({ onBack }: { onBack: () => void }) { +export function SettingsView({ + onBack, + initialTab, +}: { + onBack: () => void; + initialTab?: SectionId; +}) { const { t } = useTranslation(); - const [requestedTab, setActiveTab] = useState(null); + const showPersonalInstructions = usePersonalInstructionsAvailable(); + const [requestedTab, setActiveTab] = useState(initialTab ?? "models"); const [dirty, setDirty] = useState(false); const [pendingTab, setPendingTab] = useState(null); @@ -83,6 +103,14 @@ export function SettingsView({ onBack }: { onBack: () => void }) { const groups: Group[] = ( [ + { + id: "personal", + sections: [ + { id: "appearance", icon: Palette, visible: true }, + { id: "language", icon: Languages, visible: true }, + { id: "chats", icon: MessageSquare, visible: true }, + ], + }, { id: "agent", sections: [ @@ -258,10 +286,23 @@ export function SettingsView({ onBack }: { onBack: () => void }) { {t(`settings.sectionDescriptions.${activeTab}`)}

+ {activeTab === "appearance" && } + {activeTab === "language" && } + {activeTab === "chats" && } {activeTab === "models" && } {activeTab === "integrations" && } {activeTab === "skills" && } - {activeTab === "instructions" && } + {activeTab === "instructions" && ( +
+ + {showPersonalInstructions && ( +
+

{t("profile.personalInstructions")}

+ +
+ )} +
+ )} {activeTab === "users" && } {activeTab === "teams" && } {activeTab === "paths" && ( diff --git a/src/components/sidebar/SidebarFooterContent.tsx b/src/components/sidebar/SidebarFooterContent.tsx index c66bb20a..5b63309d 100644 --- a/src/components/sidebar/SidebarFooterContent.tsx +++ b/src/components/sidebar/SidebarFooterContent.tsx @@ -1,3 +1,5 @@ +import type { SectionId } from "@/components/settings/SettingsView"; +import { QuickPreferences } from "@/features/profile/QuickPreferences"; import { FolderOpen, Settings, SlidersHorizontal } from "lucide-react"; import { useTranslation } from "react-i18next"; import { @@ -20,7 +22,7 @@ export function SidebarFooterContent({ }: { activeSessionDirectory: string | null; homeDir: string | null; - onOpenSettings: () => void; + onOpenSettings: (tab?: SectionId) => void; settingsActive: boolean; onToggleProfile: () => void; profileOpen: boolean; @@ -47,35 +49,6 @@ export function SidebarFooterContent({
)} - - - {signedIn ? ( - - ) : ( - - )} - {profileLabel} - - onOpenSettings()} className="size-8 justify-center p-0 text-muted-foreground" > + + onOpenSettings("appearance")} /> + + {signedIn && ( + + + {signedIn ? ( + + ) : ( + + )} + {profileLabel} + + + )} ); diff --git a/src/features/profile/FontSizeSetting.tsx b/src/features/profile/FontSizeSetting.tsx new file mode 100644 index 00000000..d291f153 --- /dev/null +++ b/src/features/profile/FontSizeSetting.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import { useFontSize } from "@/hooks/use-font-size"; + +export function FontSizeSetting() { + const { t } = useTranslation(); + const [size, setSize] = useFontSize(); + return ( +
+
+ + {size}px +
+ setSize(Array.isArray(value) ? value[0]! : value)} + /> +
+ ); +} diff --git a/src/features/profile/PreferenceControls.tsx b/src/features/profile/PreferenceControls.tsx index f50050d3..e59d5190 100644 --- a/src/features/profile/PreferenceControls.tsx +++ b/src/features/profile/PreferenceControls.tsx @@ -20,7 +20,7 @@ import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { NOTIFICATIONS_ENABLED_KEY } from "@/hooks/use-agent-state"; -import { useChatWidth, type ChatWidth } from "@/hooks/use-chat-width"; +import { useChatWidth } from "@/hooks/use-chat-width"; import { useTheme, type ThemeMode } from "@/hooks/use-theme"; import { detectSystemLanguage, SUPPORTED_LANGUAGE_LIST, type SupportedLanguage } from "@/i18n"; import { DEFAULT_MODEL_MAX_AGE_MONTHS, STORAGE_KEYS } from "@/lib/constants"; @@ -207,27 +207,25 @@ export function ContrastSetting() { export function ChatWidthSetting() { const { t } = useTranslation(); const [chatWidth, setChatWidth] = useChatWidth(); + const id = useId(); return ( - - { - if (value) setChatWidth(value as ChatWidth); - }} - className="flex w-full" - > - - {t("settings.general.chatWidthStandard")} - - - {t("settings.general.chatWidthFull")} - - - +
+
+ + {chatWidth}% +
+ setChatWidth(value ?? 70)} + min={40} + max={100} + step={1} + /> +
); } diff --git a/src/features/profile/PreferenceSections.tsx b/src/features/profile/PreferenceSections.tsx new file mode 100644 index 00000000..fc53d71e --- /dev/null +++ b/src/features/profile/PreferenceSections.tsx @@ -0,0 +1,34 @@ +import { + AccentSetting, + ChatWidthSetting, + ContrastSetting, + ModelAgeFilterSetting, + NewChatModelSetting, + NotificationsSetting, + RespondingSidebarSetting, + ThemeSetting, +} from "./PreferenceControls"; +import { FontSizeSetting } from "./FontSizeSetting"; + +export function AppearanceSettings() { + return ( +
+ + + + + +
+ ); +} + +export function ChatPreferences() { + return ( +
+ + + + +
+ ); +} diff --git a/src/features/profile/ProfilePanel.render.test.tsx b/src/features/profile/ProfilePanel.render.test.tsx index c44bf735..8d849508 100644 --- a/src/features/profile/ProfilePanel.render.test.tsx +++ b/src/features/profile/ProfilePanel.render.test.tsx @@ -66,15 +66,11 @@ describe("ProfilePanel", () => { fixture.actor = { type: "local", id: "local", displayName: "Local" }; }); - test("starts as a short menu that summarises each group", () => { + test("keeps account actions separate from centralized preferences", () => { render(); expect(screen.getByRole("heading", { name: "profile.preferencesTitle" })).toBeTruthy(); - expect( - screen.getByRole("button", { name: /profile.sections.appearance/u }).textContent, - ).toContain("settings.general.themeDark"); - expect( - screen.getByRole("button", { name: /settings.general.language/u }).textContent, - ).toContain("common.autoDetect"); + expect(screen.queryByRole("button", { name: /profile.sections.appearance/u })).toBeNull(); + expect(screen.queryByRole("button", { name: /settings.general.language/u })).toBeNull(); expect(screen.getByRole("button", { name: /profile.personalInstructions/u })).toBeTruthy(); // Nothing is expanded until asked for. expect(screen.queryByText("theme")).toBeNull(); @@ -86,12 +82,9 @@ describe("ProfilePanel", () => { test("opens one group at a time and returns to the menu", async () => { const onClose = vi.fn(); render(); - await userEvent.click(screen.getByRole("button", { name: /profile.sections.appearance/u })); - expect(screen.getByRole("heading", { name: "profile.sections.appearance" })).toBeTruthy(); - for (const control of ["theme", "accent", "contrast", "chat-width"]) { - expect(screen.getByText(control)).toBeTruthy(); - } - expect(screen.queryByText("notifications")).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: /profile.personalInstructions/u })); + expect(screen.getByRole("heading", { name: "profile.personalInstructions" })).toBeTruthy(); + expect(screen.getByText("personal-instructions")).toBeTruthy(); await userEvent.click(screen.getByRole("button", { name: "common.back" })); expect(screen.getByRole("heading", { name: "profile.preferencesTitle" })).toBeTruthy(); diff --git a/src/features/profile/ProfilePanel.tsx b/src/features/profile/ProfilePanel.tsx index 9dec5d72..26fa7e5f 100644 --- a/src/features/profile/ProfilePanel.tsx +++ b/src/features/profile/ProfilePanel.tsx @@ -1,19 +1,9 @@ -import { - ArrowLeft, - ChevronRight, - Languages, - LogOut, - MessageSquare, - Palette, - ScrollText, - type LucideIcon, -} from "lucide-react"; +import { ArrowLeft, ChevronRight, LogOut, ScrollText, type LucideIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"; -import { useTheme } from "@/hooks/use-theme"; import { AccountSection, initialsFor } from "@/features/identity/AccountSection"; import { useIdentityActor } from "@/features/identity/identity-actor-context"; import { logoutActiveWorkspaceIdentity } from "@/features/identity/workspace-identity"; @@ -21,20 +11,7 @@ import { PersonalInstructions, usePersonalInstructionsAvailable, } from "@/features/instructions/InstructionsSettings"; -import { - AccentSetting, - ChatWidthSetting, - ContrastSetting, - LanguageOptions, - ModelAgeFilterSetting, - NewChatModelSetting, - NotificationsSetting, - RespondingSidebarSetting, - ThemeSetting, - useLanguagePreference, -} from "./PreferenceControls"; - -type View = "home" | "account" | "appearance" | "language" | "chats" | "instructions"; +type View = "home" | "account" | "instructions"; function MenuRow({ icon: Icon, @@ -70,16 +47,9 @@ function Home({ }) { const { t } = useTranslation(); const actor = useIdentityActor(); - const { mode } = useTheme(); - const [language] = useLanguagePreference(); + const [signingOut, setSigningOut] = useState(false); const signedIn = actor?.type === "user"; - const themeLabel = { - light: t("settings.general.themeLight"), - dark: t("settings.general.themeDark"), - system: t("settings.general.themeSystem"), - }[mode]; - return (
{signedIn && ( @@ -108,34 +78,15 @@ function Home({ )} - + )} {signedIn && ( @@ -205,9 +156,6 @@ export function ProfilePanel({ onClose }: { onClose: () => void }) { const title = { home: signedIn ? t("profile.title") : t("profile.preferencesTitle"), account: t("profile.account"), - appearance: t("profile.sections.appearance"), - language: t("settings.general.language"), - chats: t("profile.sections.chats"), instructions: t("profile.personalInstructions"), }[view]; @@ -250,27 +198,6 @@ export function ProfilePanel({ onClose }: { onClose: () => void }) { )} - {view === "appearance" && ( - - - - - - - )} - {view === "language" && ( -
- -
- )} - {view === "chats" && ( - - - - - - - )} {view === "instructions" && ( diff --git a/src/features/profile/QuickPreferences.tsx b/src/features/profile/QuickPreferences.tsx new file mode 100644 index 00000000..ba68d548 --- /dev/null +++ b/src/features/profile/QuickPreferences.tsx @@ -0,0 +1,89 @@ +import { Palette } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { SidebarMenuButton } from "@/components/ui/sidebar"; +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AccentSetting, + ChatWidthSetting, + ContrastSetting, + ThemeSetting, + useLanguagePreference, +} from "./PreferenceControls"; +import { FontSizeSetting } from "./FontSizeSetting"; +import { SUPPORTED_LANGUAGE_LIST, type SupportedLanguage } from "@/i18n"; + +export function QuickPreferences({ onOpenAppearance }: { onOpenAppearance: () => void }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [language, choose] = useLanguagePreference(); + return ( + + + + + + + + {t("settings.quick.title")} + + + + + +
+ + {t("settings.general.language")} + + +
+ +
+
+ ); +} diff --git a/src/features/session-transcript/transcript-viewport.test.tsx b/src/features/session-transcript/transcript-viewport.test.tsx index 798b8bbc..02a44e22 100644 --- a/src/features/session-transcript/transcript-viewport.test.tsx +++ b/src/features/session-transcript/transcript-viewport.test.tsx @@ -71,9 +71,12 @@ describe("TranscriptViewport scroll anchoring", () => { , ); const content = screen.getByText("message").parentElement!; - expect(content.classList.contains("max-w-2xl")).toBe(true); + expect(content.classList.contains("md:w-[var(--chat-width)]")).toBe(true); + expect(content.style.getPropertyValue("--chat-width")).toBe("70%"); act(() => storageSet(STORAGE_KEYS.CHAT_WIDTH, "full")); - expect(content.classList.contains("max-w-none")).toBe(true); + expect(content.style.getPropertyValue("--chat-width")).toBe("100%"); + act(() => storageSet(STORAGE_KEYS.CHAT_WIDTH, "83")); + expect(content.style.getPropertyValue("--chat-width")).toBe("83%"); view.unmount(); }); diff --git a/src/features/session-transcript/transcript-viewport.tsx b/src/features/session-transcript/transcript-viewport.tsx index 9b40ff95..05ad1a02 100644 --- a/src/features/session-transcript/transcript-viewport.tsx +++ b/src/features/session-transcript/transcript-viewport.tsx @@ -78,7 +78,8 @@ export function TranscriptViewport({ >
{showLoadOlderRow && (
diff --git a/src/frontend.tsx b/src/frontend.tsx index 9c1e8d7a..0ec29803 100644 --- a/src/frontend.tsx +++ b/src/frontend.tsx @@ -1,3 +1,4 @@ +import { applyStoredFontSize } from "./hooks/use-font-size"; /** * This file is the entry point for the React app, it sets up the root * element and renders the App component to the DOM. @@ -28,6 +29,7 @@ preventDocumentPinchZoom(); installWebElectronAPI(); getDesktopShellClient(); applyStoredAppearance(); +applyStoredFontSize(); const elem = document.getElementById("root"); if (!elem) throw new Error("Root element not found"); diff --git a/src/hooks/use-chat-width.test.tsx b/src/hooks/use-chat-width.test.tsx index c2a29fea..219c722b 100644 --- a/src/hooks/use-chat-width.test.tsx +++ b/src/hooks/use-chat-width.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, test } from "vite-plus/test"; import { STORAGE_KEYS } from "@/lib/constants"; import { storageSet } from "@/lib/persistence/storage"; @@ -7,7 +7,7 @@ import { useChatWidth } from "./use-chat-width"; function WidthDisplay() { const [width, setWidth] = useChatWidth(); - return ; + return ; } describe("chat width setting", () => { @@ -19,7 +19,17 @@ describe("chat width setting", () => { test("defaults to standard for missing and invalid values", () => { storageSet(STORAGE_KEYS.CHAT_WIDTH, "invalid"); render(); - expect(screen.getByRole("button").textContent).toBe("standard"); + expect(screen.getByRole("button").textContent).toBe("70"); + }); + + test("reads legacy full width and supports percentages", () => { + storageSet(STORAGE_KEYS.CHAT_WIDTH, "full"); + render(); + expect(screen.getByRole("button").textContent).toBe("100"); + act(() => storageSet(STORAGE_KEYS.CHAT_WIDTH, "83")); + expect(screen.getByRole("button").textContent).toBe("83"); + act(() => storageSet(STORAGE_KEYS.CHAT_WIDTH, "101")); + expect(screen.getByRole("button").textContent).toBe("70"); }); test("updates mounted consumers immediately and persists the choice", () => { @@ -31,12 +41,12 @@ describe("chat width setting", () => { ); fireEvent.click(screen.getAllByRole("button")[0]!); expect(screen.getAllByRole("button").map((button) => button.textContent)).toEqual([ - "full", - "full", + "100", + "100", ]); - expect(localStorage.getItem(STORAGE_KEYS.CHAT_WIDTH)).toBe("full"); + expect(localStorage.getItem(STORAGE_KEYS.CHAT_WIDTH)).toBe("100"); cleanup(); render(); - expect(screen.getByRole("button").textContent).toBe("full"); + expect(screen.getByRole("button").textContent).toBe("100"); }); }); diff --git a/src/hooks/use-chat-width.ts b/src/hooks/use-chat-width.ts index 2d2a7346..9698fe9c 100644 --- a/src/hooks/use-chat-width.ts +++ b/src/hooks/use-chat-width.ts @@ -2,10 +2,13 @@ import { useSyncExternalStore } from "react"; import { STORAGE_KEYS } from "@/lib/constants"; import { onSettingsChange, storageGet, storageSet } from "@/lib/persistence/storage"; -export type ChatWidth = "standard" | "full"; +export type ChatWidth = number; function getChatWidth(): ChatWidth { - return storageGet(STORAGE_KEYS.CHAT_WIDTH) === "full" ? "full" : "standard"; + const stored = storageGet(STORAGE_KEYS.CHAT_WIDTH); + if (stored === "full") return 100; + const value = Number(stored); + return Number.isInteger(value) && value >= 40 && value <= 100 ? value : 70; } function subscribe(callback: () => void): () => void { @@ -14,7 +17,7 @@ function subscribe(callback: () => void): () => void { }); // Browser storage events from other tabs are not dispatched via onSettingsChange. const onStorage = (event: StorageEvent) => { - if (event.key === STORAGE_KEYS.CHAT_WIDTH) callback(); + if (event.key === STORAGE_KEYS.CHAT_WIDTH || event.key === null) callback(); }; window.addEventListener("storage", onStorage); return () => { @@ -24,6 +27,13 @@ function subscribe(callback: () => void): () => void { } export function useChatWidth(): [ChatWidth, (value: ChatWidth) => void] { - const width = useSyncExternalStore(subscribe, getChatWidth, (): ChatWidth => "standard"); - return [width, (value) => storageSet(STORAGE_KEYS.CHAT_WIDTH, value)]; + const width = useSyncExternalStore(subscribe, getChatWidth, (): ChatWidth => 70); + return [ + width, + (value) => { + if (Number.isInteger(value) && value >= 40 && value <= 100) { + storageSet(STORAGE_KEYS.CHAT_WIDTH, String(value)); + } + }, + ]; } diff --git a/src/hooks/use-font-size.test.tsx b/src/hooks/use-font-size.test.tsx new file mode 100644 index 00000000..ed63c4bf --- /dev/null +++ b/src/hooks/use-font-size.test.tsx @@ -0,0 +1,25 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, expect, test } from "vite-plus/test"; +import { storageSet } from "@/lib/persistence/storage"; +import { STORAGE_KEYS } from "@/lib/constants"; +import { applyStoredFontSize, useFontSize } from "./use-font-size"; + +afterEach(() => { + cleanup(); + localStorage.clear(); + document.documentElement.style.fontSize = ""; +}); + +test("validates persisted sizes and synchronizes both settings entry points", () => { + storageSet(STORAGE_KEYS.FONT_SIZE, "99"); + applyStoredFontSize(); + expect(document.documentElement.style.fontSize).toBe("16px"); + const first = renderHook(() => useFontSize()); + const second = renderHook(() => useFontSize()); + act(() => first.result.current[1](18)); + expect(second.result.current[0]).toBe(18); + expect(document.documentElement.style.fontSize).toBe("18px"); + act(() => second.result.current[1](0)); + expect(first.result.current[0]).toBe(18); +}); diff --git a/src/hooks/use-font-size.ts b/src/hooks/use-font-size.ts new file mode 100644 index 00000000..8f2b60bc --- /dev/null +++ b/src/hooks/use-font-size.ts @@ -0,0 +1,41 @@ +import { useSyncExternalStore } from "react"; +import { STORAGE_KEYS } from "@/lib/constants"; +import { onSettingsChange, storageGet, storageSet } from "@/lib/persistence/storage"; + +function getSize() { + const value = Number(storageGet(STORAGE_KEYS.FONT_SIZE)); + return Number.isFinite(value) && value >= 12 && value <= 20 ? value : 16; +} + +export function applyStoredFontSize() { + document.documentElement.style.fontSize = `${getSize()}px`; +} + +function subscribe(callback: () => void) { + const update = () => { + applyStoredFontSize(); + callback(); + }; + const unsubscribe = onSettingsChange(({ key }) => { + if (key === STORAGE_KEYS.FONT_SIZE) update(); + }); + const onStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEYS.FONT_SIZE || event.key === null) update(); + }; + window.addEventListener("storage", onStorage); + return () => { + unsubscribe(); + window.removeEventListener("storage", onStorage); + }; +} + +export function useFontSize(): [number, (size: number) => void] { + const size = useSyncExternalStore(subscribe, getSize, () => 16); + return [ + size, + (value) => { + if (Number.isInteger(value) && value >= 12 && value <= 20) + storageSet(STORAGE_KEYS.FONT_SIZE, String(value)); + }, + ]; +} diff --git a/src/hooks/use-theme.test.tsx b/src/hooks/use-theme.test.tsx index a5751f6d..4adf2ab0 100644 --- a/src/hooks/use-theme.test.tsx +++ b/src/hooks/use-theme.test.tsx @@ -49,6 +49,18 @@ describe("appearance persistence", () => { ); }); + test("keeps simultaneous appearance controls synchronized", () => { + vi.spyOn(window, "matchMedia").mockReturnValue(mediaQuery(false) as never); + const first = renderHook(() => useTheme()); + const second = renderHook(() => useTheme()); + act(() => first.result.current.setTheme("dark")); + expect(second.result.current.mode).toBe("dark"); + act(() => second.result.current.setAccentColor("#5482ff")); + expect(first.result.current.accentColor).toBe("#5482ff"); + first.unmount(); + second.unmount(); + }); + test("follows system changes, persists controls, and cleans up", () => { const query = mediaQuery(true); vi.spyOn(window, "matchMedia").mockReturnValue(query as never); diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts index 22905e50..cb1c546e 100644 --- a/src/hooks/use-theme.ts +++ b/src/hooks/use-theme.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { STORAGE_KEYS } from "@/lib/constants"; -import { storageGet, storageSet } from "@/lib/persistence/storage"; +import { onSettingsChange, storageGet, storageSet } from "@/lib/persistence/storage"; export type ThemeMode = "dark" | "light" | "system"; /** Resolved actual theme (never "system") */ @@ -122,6 +122,24 @@ export function useTheme() { applyTheme(mode, contrast, accentColor); }, [mode, contrast, accentColor]); + // All mounted controls share the same persisted preferences. + useEffect(() => { + const sync = () => { + setModeState(getStoredMode()); + setContrastState(getStoredContrast()); + setAccentColorState(getStoredAccentColor()); + }; + const unsubscribe = onSettingsChange(({ key }) => { + if ([STORAGE_KEYS.THEME, STORAGE_KEYS.CONTRAST, STORAGE_KEYS.ACCENT_COLOR].includes(key)) + sync(); + }); + window.addEventListener("storage", sync); + return () => { + unsubscribe(); + window.removeEventListener("storage", sync); + }; + }, []); + // Follow system preference changes when mode is "system" useEffect(() => { if (mode !== "system") return; diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6d584f78..9ada6356 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -180,7 +180,10 @@ "plugins": "Plugins", "tools": "Werkzeuge", "teams": "Teams", - "desktop": "Desktop" + "desktop": "Desktop", + "appearance": "Darstellung", + "language": "Sprache", + "chats": "Chats" }, "sectionDescriptions": { "models": "Modelle auswählen und den Zugriff verwalten.", @@ -191,7 +194,10 @@ "instructions": "Hausregeln, denen der Agent auf diesem Host und in gemeinsamen Projekten folgt. Ihre persönlichen Vorgaben finden Sie in Ihrem Profil in der Seitenleiste.", "skills": "Wiederverwendbare Anweisungen für den Agenten.", "teams": "Personen gruppieren und ihren Zugriff gemeinsam verwalten.", - "desktop": "Wo neue Chats beginnen und welche Apps auf diesem Computer Dateien und Terminals öffnen." + "desktop": "Wo neue Chats beginnen und welche Apps auf diesem Computer Dateien und Terminals öffnen.", + "appearance": "Passe Design, Schriftgröße und Layout von OpenGUI an.", + "language": "Wähle die Sprache der Oberfläche.", + "chats": "Lege Vorgaben und Benachrichtigungen für deine Chats fest." }, "paths": { "boundaryTitle": "Pfadfreigaben sind keine Sandbox", @@ -264,15 +270,21 @@ "restartLocalBackendDescription": "Dadurch wird das lokale OpenGUI-Backend neu geladen und die App anschließend aktualisiert. Laufende Vorgänge können unterbrochen werden.", "theme": "Design", "olderThan": "Älter als", - "blockedByBrowserHelp": "Erlauben Sie Benachrichtigungen für diese Seite in den Browsereinstellungen, um sie einzuschalten." + "blockedByBrowserHelp": "Erlauben Sie Benachrichtigungen für diese Seite in den Browsereinstellungen, um sie einzuschalten.", + "fontSize": "Schriftgröße" }, "groups": { "agent": "Agent", "host": "Host", - "device": "Dieser Computer" + "device": "Dieser Computer", + "personal": "Persönlich" }, "backToChat": "Zurück zum Chat", - "version": "OpenGUI {{version}}" + "version": "OpenGUI {{version}}", + "quick": { + "title": "Schnelleinstellungen", + "allAppearance": "Alle Darstellungseinstellungen …" + } }, "sidebar": { "allProjectsPinned": "Alle Projekte sind angeheftet", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8c8a99ae..b02631dd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -180,7 +180,10 @@ "plugins": "Plugins", "tools": "Tools", "teams": "Teams", - "desktop": "Desktop" + "desktop": "Desktop", + "appearance": "Appearance", + "language": "Language", + "chats": "Chats" }, "sectionDescriptions": { "models": "Choose models and who can use them.", @@ -191,7 +194,10 @@ "instructions": "House rules the agent follows on this Host and in shared projects. Your personal preferences live in your profile in the sidebar.", "skills": "Reusable instructions available to the agent.", "teams": "Group people and manage their access together.", - "desktop": "Where new chats start and which apps open files and terminals on this computer." + "desktop": "Where new chats start and which apps open files and terminals on this computer.", + "appearance": "Customize the look, text size and layout of OpenGUI.", + "language": "Choose the interface language.", + "chats": "Set defaults and notifications for your chats." }, "paths": { "boundaryTitle": "Path grants are not a sandbox", @@ -264,15 +270,21 @@ "restartLocalBackendDescription": "This reloads OpenGUI's local backend and then refreshes the app. Running turns may be interrupted.", "theme": "Theme", "olderThan": "Older than", - "blockedByBrowserHelp": "Allow notifications for this site in your browser settings to turn them on." + "blockedByBrowserHelp": "Allow notifications for this site in your browser settings to turn them on.", + "fontSize": "Font size" }, "groups": { "agent": "Agent", "host": "Host", - "device": "This computer" + "device": "This computer", + "personal": "Personal" }, "backToChat": "Back to chat", - "version": "OpenGUI {{version}}" + "version": "OpenGUI {{version}}", + "quick": { + "title": "Quick preferences", + "allAppearance": "All appearance settings…" + } }, "sidebar": { "allProjectsPinned": "All projects pinned", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f552b6cf..f5420849 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -180,7 +180,10 @@ "plugins": "Plugins", "tools": "Herramientas", "teams": "Equipos", - "desktop": "Escritorio" + "desktop": "Escritorio", + "appearance": "Apariencia", + "language": "Idioma", + "chats": "Chats" }, "sectionDescriptions": { "models": "Elige modelos y quién puede usarlos.", @@ -191,7 +194,10 @@ "instructions": "Reglas que el agente sigue en este Host y en los proyectos compartidos. Tus preferencias personales están en tu perfil, en la barra lateral.", "skills": "Instrucciones reutilizables disponibles para el agente.", "teams": "Agrupa personas y gestiona su acceso.", - "desktop": "Dónde empiezan los chats nuevos y qué apps abren archivos y terminales en este equipo." + "desktop": "Dónde empiezan los chats nuevos y qué apps abren archivos y terminales en este equipo.", + "appearance": "Personaliza el diseño, el tamaño del texto y la distribución de OpenGUI.", + "language": "Elige el idioma de la interfaz.", + "chats": "Configura los valores predeterminados y las notificaciones de tus chats." }, "paths": { "boundaryTitle": "Los permisos de ruta no son un sandbox", @@ -264,15 +270,21 @@ "restartLocalBackendDescription": "Esto recarga el backend local de OpenGUI y luego actualiza la app. Los turnos en ejecución pueden interrumpirse.", "theme": "Tema", "olderThan": "Con más de", - "blockedByBrowserHelp": "Permite las notificaciones de este sitio en la configuración del navegador para activarlas." + "blockedByBrowserHelp": "Permite las notificaciones de este sitio en la configuración del navegador para activarlas.", + "fontSize": "Tamaño de fuente" }, "groups": { "agent": "Agente", "host": "Host", - "device": "Este equipo" + "device": "Este equipo", + "personal": "Personal" }, "backToChat": "Volver al chat", - "version": "OpenGUI {{version}}" + "version": "OpenGUI {{version}}", + "quick": { + "title": "Ajustes rápidos", + "allAppearance": "Todos los ajustes de apariencia…" + } }, "sidebar": { "allProjectsPinned": "Todos los proyectos están fijados", diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5512f309..6f81545f 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -31,6 +31,7 @@ export const STORAGE_KEYS = { MODEL_MAX_AGE_MONTHS: key("modelMaxAgeMonths"), NEW_CHAT_MODEL_BEHAVIOR: key("newChatModelBehavior"), LANGUAGE: key("language"), + FONT_SIZE: key("fontSize"), THEME: "theme", CHAT_WIDTH: key("chatWidth"), SIDEBAR_WIDTH: key("sidebarWidth"), From e8ad4122ce35c181fbb798215e3cdbe953772e5c Mon Sep 17 00:00:00 2001 From: akemmanuel <118383304+akemmanuel@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:43:50 -0300 Subject: [PATCH 3/3] release: prepare v0.7.1 --- CHANGELOG.md | 23 +++++++++++++++++++++++ docker/sandbox/compose.yml | 2 +- docs/docker.md | 4 ++-- package.json | 2 +- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ede15e8..61302d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.7.1 + +### Chat and sidebar + +- Navigate long conversations with a prompt rail, prompt previews, and keyboard shortcuts: + `Alt+Up` / `Alt+Down` move between prompts and `Alt+End` jumps to the latest message. +- A scroll-to-end button shows when you are reading earlier messages and highlights new output + without pulling you away from your place. +- Fully hide the sidebar or drag its edge to resize it. The width is remembered; double-click + the resize handle to reset it. Keyboard resizing is also supported. +- Completed turns stop their thinking and response timers reliably, even when delayed Session + reads or stream reconnects arrive after completion. Reconnecting also repairs missed events. + +### Preferences + +- Appearance, language, and chat preferences are grouped in Settings, with the chat sidebar + still available while Settings is open. +- Open quick preferences beside the Settings button to change theme, accent, font size, + contrast, chat width, and language. +- Adjust chat width continuously from 40% to 100%; the transcript and prompt share the setting, + and existing width preferences carry over. +- Updated English, German, and Spanish translations for the new controls. + ## 0.7.0 OpenGUI 0.7.0 brings the release in line with what has been running on managed Hosts, and diff --git a/docker/sandbox/compose.yml b/docker/sandbox/compose.yml index ebd25d12..cdd9ad34 100644 --- a/docker/sandbox/compose.yml +++ b/docker/sandbox/compose.yml @@ -2,7 +2,7 @@ name: "${OPENGUI_SANDBOX_NAME:-opengui-sandbox}" services: opengui: - image: "${OPENGUI_IMAGE:-ghcr.io/akemmanuel/opengui:0.7.0}" + image: "${OPENGUI_IMAGE:-ghcr.io/akemmanuel/opengui:0.7.1}" container_name: "${OPENGUI_SANDBOX_NAME:-opengui-sandbox}" runtime: "${OPENGUI_SANDBOX_RUNTIME:-runsc}" restart: unless-stopped diff --git a/docs/docker.md b/docs/docker.md index f0f5ad52..4e666f68 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -12,10 +12,10 @@ docker compose up -d curl --fail http://127.0.0.1:${PORT:-4839}/api/health ``` -For this prerelease, select the RC explicitly because prereleases never update `latest`: +To pin the Host to this release instead of following `latest`: ```bash -OPENGUI_IMAGE=ghcr.io/akemmanuel/opengui:0.7.0 docker compose up -d +OPENGUI_IMAGE=ghcr.io/akemmanuel/opengui:0.7.1 docker compose up -d ``` Open `http://127.0.0.1:4839`. The first browser creates the Host owner Account. The compose file diff --git a/package.json b/package.json index 6e02218c..e293efc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opengui", - "version": "0.7.0", + "version": "0.7.1", "private": false, "description": "OpenGUI - first-party coding agent for desktop and web", "homepage": "https://github.com/akemmanuel/OpenGUI",