diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f788263fac71..bd55691ed566 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -62,6 +62,9 @@ const clientSettings: ClientSettings = { sidebarThreadPreviewCount: 6, legacySidebarEnabled: false, legacySidebarScale: 100, + largerScrollbarsEnabled: true, + scrollbarWidth: 10, + scrollbarMargin: 4, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5730d1fcb606..f002dcb8de58 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1918,7 +1918,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ > {/* The full path: the chip already shows the shortened form, and a link to the workspace root collapses to a bare label that repeats it. */} -
diff --git a/apps/web/src/components/settings/LastCodeSettings.tsx b/apps/web/src/components/settings/LastCodeSettings.tsx
index edb4ccfe70fb..9eba085335f1 100644
--- a/apps/web/src/components/settings/LastCodeSettings.tsx
+++ b/apps/web/src/components/settings/LastCodeSettings.tsx
@@ -5,9 +5,15 @@ import {
} from "@t3tools/contracts";
import {
DEFAULT_LEGACY_SIDEBAR_SCALE,
+ DEFAULT_SCROLLBAR_MARGIN,
+ DEFAULT_SCROLLBAR_WIDTH,
LEGACY_SIDEBAR_SCALE_REFERENCE,
MAX_LEGACY_SIDEBAR_SCALE,
+ MAX_SCROLLBAR_MARGIN,
+ MAX_SCROLLBAR_WIDTH,
MIN_LEGACY_SIDEBAR_SCALE,
+ MIN_SCROLLBAR_MARGIN,
+ MIN_SCROLLBAR_WIDTH,
} from "@t3tools/contracts/settings";
import { useAtomValue } from "@effect/atom-react";
import { DownloadIcon, MoonStarIcon, PaletteIcon, ServerIcon } from "lucide-react";
@@ -46,6 +52,56 @@ const WORKTREE_INDICATOR_PREVIEW_THREAD = {
worktreePath: "/example/worktrees/example",
};
+function PixelSlider({
+ id,
+ label,
+ min,
+ max,
+ value,
+ onChange,
+}: {
+ id: string;
+ label: string;
+ min: number;
+ max: number;
+ value: number;
+ onChange: (value: number) => void;
+}) {
+ const ratio = (value - min) / (max - min);
+ const sliderStyle = {
+ "--settings-slider-progress": `${ratio * 100}%`,
+ "--settings-slider-fill-offset": `${0.5 - ratio}rem`,
+ } as CSSProperties;
+
+ return (
+
+
+ {
+ const nextValue = Number(event.currentTarget.value);
+ if (Number.isInteger(nextValue) && nextValue >= min && nextValue <= max) {
+ onChange(nextValue);
+ }
+ }}
+ step={1}
+ style={sliderStyle}
+ type="range"
+ value={value}
+ />
+
+ );
+}
+
export function LastCodeSettingsPanel() {
const updateState = useDesktopUpdateState();
const clientSettings = usePrimarySettings();
@@ -182,6 +238,72 @@ export function LastCodeSettingsPanel() {
/>
}>
+
+ updateClientSettings({ largerScrollbarsEnabled: Boolean(checked) })
+ }
+ aria-label="Larger scrollbars"
+ />
+ }
+ />
+ {clientSettings.largerScrollbarsEnabled ? (
+ <>
+
+ updateClientSettings({ scrollbarWidth: DEFAULT_SCROLLBAR_WIDTH })
+ }
+ />
+ ) : null
+ }
+ control={
+ updateClientSettings({ scrollbarWidth })}
+ />
+ }
+ />
+
+ updateClientSettings({ scrollbarMargin: DEFAULT_SCROLLBAR_MARGIN })
+ }
+ />
+ ) : null
+ }
+ control={
+ updateClientSettings({ scrollbarMargin })}
+ />
+ }
+ />
+ >
+ ) : null}
{
});
});
+ it("routes scrollbar sizing to LastCode settings", () => {
+ expect(searchSettings("scrollbar margin")[0]).toMatchObject({
+ id: "larger-scrollbars",
+ to: "/settings/lastcode",
+ });
+ });
+
it("routes project icon rounding to LastCode settings", () => {
expect(searchSettings("rounded project icons")[0]).toMatchObject({
id: "rounded-project-icons",
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index 13e2a7ddff97..805217d7fad7 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -493,6 +493,12 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Show and install local nightlies",
to: "/settings/lastcode",
},
+ {
+ id: "larger-scrollbars",
+ title: "Larger scrollbars",
+ to: "/settings/lastcode",
+ searchTerms: ["width margin resize handle pane drag thumb"],
+ },
{
id: "scale-legacy-sidebar",
title: "Scale legacy sidebar",
diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx
index bfc10825b460..4dad6bd3c084 100644
--- a/apps/web/src/components/ui/scroll-area.tsx
+++ b/apps/web/src/components/ui/scroll-area.tsx
@@ -73,7 +73,7 @@ function ScrollBar({
return (
+
{primaryEnvironmentAuthenticated ? : null}
{primaryEnvironmentAuthenticated ? : null}
@@ -204,6 +206,30 @@ function ProjectIconAppearanceSync() {
return null;
}
+function ScrollbarAppearanceSync() {
+ const largerScrollbarsEnabled = useClientSettings((settings) => settings.largerScrollbarsEnabled);
+ const scrollbarWidth = useClientSettings((settings) => settings.scrollbarWidth);
+ const scrollbarMargin = useClientSettings((settings) => settings.scrollbarMargin);
+
+ useEffect(() => {
+ const root = document.documentElement;
+ applyScrollbarAppearance(root, {
+ enabled: largerScrollbarsEnabled,
+ width: scrollbarWidth,
+ margin: scrollbarMargin,
+ });
+ return () => {
+ applyScrollbarAppearance(root, {
+ enabled: false,
+ width: scrollbarWidth,
+ margin: scrollbarMargin,
+ });
+ };
+ }, [largerScrollbarsEnabled, scrollbarMargin, scrollbarWidth]);
+
+ return null;
+}
+
function FontAppearanceSync() {
const fontFamilySans = useClientSettings((settings) => settings.fontFamilySans);
const fontFamilyCode = useClientSettings((settings) => settings.fontFamilyCode);
diff --git a/apps/web/src/scrollbarAppearance.test.ts b/apps/web/src/scrollbarAppearance.test.ts
new file mode 100644
index 000000000000..08288492acad
--- /dev/null
+++ b/apps/web/src/scrollbarAppearance.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import { applyScrollbarAppearance } from "./scrollbarAppearance";
+
+describe("applyScrollbarAppearance", () => {
+ it("sets the thumb, margin, and total hit-lane sizes", () => {
+ const { root, setProperty, toggleAttribute } = makeRoot();
+
+ applyScrollbarAppearance(root, { enabled: true, width: 9, margin: 5 });
+
+ expect(toggleAttribute).toHaveBeenCalledWith("data-larger-scrollbars", true);
+ expect(setProperty).toHaveBeenCalledWith("--app-scrollbar-width", "9px");
+ expect(setProperty).toHaveBeenCalledWith("--app-scrollbar-margin", "5px");
+ expect(setProperty).toHaveBeenCalledWith("--app-scrollbar-lane-width", "14px");
+ expect(setProperty).toHaveBeenCalledWith("--app-native-scrollbar-margin", "5px");
+ expect(setProperty).toHaveBeenCalledWith("--app-native-scrollbar-width", "auto");
+ expect(setProperty).toHaveBeenCalledWith("--app-compact-scrollbar-height", "14px");
+ expect(setProperty).toHaveBeenCalledWith("--app-code-scrollbar-height", "14px");
+ expect(setProperty).toHaveBeenCalledWith("--app-scrollbar-thumb-inset", "0px");
+ });
+
+ it("restores the stylesheet defaults when disabled", () => {
+ const { root, removeProperty, toggleAttribute } = makeRoot();
+
+ applyScrollbarAppearance(root, { enabled: false, width: 12, margin: 6 });
+
+ expect(toggleAttribute).toHaveBeenCalledWith("data-larger-scrollbars", false);
+ expect(removeProperty).toHaveBeenCalledWith("--app-scrollbar-width");
+ expect(removeProperty).toHaveBeenCalledWith("--app-scrollbar-margin");
+ expect(removeProperty).toHaveBeenCalledWith("--app-scrollbar-lane-width");
+ expect(removeProperty).toHaveBeenCalledWith("--app-native-scrollbar-margin");
+ expect(removeProperty).toHaveBeenCalledWith("--app-native-scrollbar-width");
+ expect(removeProperty).toHaveBeenCalledWith("--app-compact-scrollbar-height");
+ expect(removeProperty).toHaveBeenCalledWith("--app-code-scrollbar-height");
+ expect(removeProperty).toHaveBeenCalledWith("--app-scrollbar-thumb-inset");
+ });
+});
+
+function makeRoot() {
+ const setProperty = vi.fn();
+ const removeProperty = vi.fn();
+ const toggleAttribute = vi.fn();
+ return {
+ root: {
+ style: { setProperty, removeProperty },
+ toggleAttribute,
+ } as unknown as HTMLElement,
+ setProperty,
+ removeProperty,
+ toggleAttribute,
+ };
+}
diff --git a/apps/web/src/scrollbarAppearance.ts b/apps/web/src/scrollbarAppearance.ts
new file mode 100644
index 000000000000..31c98da0b88a
--- /dev/null
+++ b/apps/web/src/scrollbarAppearance.ts
@@ -0,0 +1,33 @@
+import type { ScrollbarMargin, ScrollbarWidth } from "@t3tools/contracts/settings";
+
+export function applyScrollbarAppearance(
+ root: HTMLElement,
+ options: {
+ enabled: boolean;
+ width: ScrollbarWidth;
+ margin: ScrollbarMargin;
+ },
+): void {
+ root.toggleAttribute("data-larger-scrollbars", options.enabled);
+
+ if (!options.enabled) {
+ root.style.removeProperty("--app-scrollbar-width");
+ root.style.removeProperty("--app-scrollbar-margin");
+ root.style.removeProperty("--app-scrollbar-lane-width");
+ root.style.removeProperty("--app-native-scrollbar-margin");
+ root.style.removeProperty("--app-native-scrollbar-width");
+ root.style.removeProperty("--app-compact-scrollbar-height");
+ root.style.removeProperty("--app-code-scrollbar-height");
+ root.style.removeProperty("--app-scrollbar-thumb-inset");
+ return;
+ }
+
+ root.style.setProperty("--app-scrollbar-width", `${options.width}px`);
+ root.style.setProperty("--app-scrollbar-margin", `${options.margin}px`);
+ root.style.setProperty("--app-scrollbar-lane-width", `${options.width + options.margin}px`);
+ root.style.setProperty("--app-native-scrollbar-margin", `${options.margin}px`);
+ root.style.setProperty("--app-native-scrollbar-width", "auto");
+ root.style.setProperty("--app-compact-scrollbar-height", `${options.width + options.margin}px`);
+ root.style.setProperty("--app-code-scrollbar-height", `${options.width + options.margin}px`);
+ root.style.setProperty("--app-scrollbar-thumb-inset", "0px");
+}
diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts
index 84e59e10f509..05fee03fbd06 100644
--- a/apps/web/src/terminal/ghostty/surface.ts
+++ b/apps/web/src/terminal/ghostty/surface.ts
@@ -674,7 +674,7 @@ export class GhosttyTerminalSurface {
const scrollbar = document.createElement("div");
scrollbar.className =
- "group absolute top-1 right-px bottom-1 z-1 w-[var(--app-scrollbar-width)] cursor-default touch-none";
+ "group absolute top-1 right-[var(--app-scrollbar-margin)] bottom-1 z-1 w-[var(--app-scrollbar-width)] cursor-default touch-none";
scrollbar.setAttribute("role", "scrollbar");
scrollbar.setAttribute("aria-label", "Terminal scrollback");
scrollbar.setAttribute("aria-orientation", "vertical");
@@ -682,7 +682,7 @@ export class GhosttyTerminalSurface {
scrollbar.hidden = true;
const scrollbarThumb = document.createElement("div");
scrollbarThumb.className =
- "absolute inset-x-px top-0 rounded-[3px] bg-[var(--app-scrollbar-thumb)] transition-[background-color] duration-[120ms] ease-[ease-out] group-hover:bg-[var(--app-scrollbar-thumb-hover)] group-focus-visible:bg-[var(--app-scrollbar-thumb-hover)]";
+ "absolute inset-x-[var(--app-scrollbar-thumb-inset)] top-0 rounded-[3px] bg-[var(--app-scrollbar-thumb)] transition-[background-color] duration-[120ms] ease-[ease-out] group-hover:bg-[var(--app-scrollbar-thumb-hover)] group-focus-visible:bg-[var(--app-scrollbar-thumb-hover)]";
scrollbar.append(scrollbarThumb);
mount.replaceChildren(canvas, input, scrollbar);
diff --git a/docs/README.md b/docs/README.md
index 9d1e3156ee53..22294cfa3a01 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -6,6 +6,7 @@
- [Permission modes](./user/permission-modes.md)
- [Keyboard shortcuts](./user/keybindings.md)
- [Organizing threads](./user/thread-sidebar.md)
+- [LastCode appearance preferences](./user/lastcode-appearance.md)
- [Resumable Project Actions in LastCode](./user/resumable-project-actions.md)
- [Use an agent to add resumable Project Actions](./user/resumable-project-actions-for-agents.md)
- [Review usage](./user/usage.md)
diff --git a/docs/user/lastcode-appearance.md b/docs/user/lastcode-appearance.md
new file mode 100644
index 000000000000..013b29d17180
--- /dev/null
+++ b/docs/user/lastcode-appearance.md
@@ -0,0 +1,19 @@
+# LastCode appearance preferences
+
+## Make scrollbars easier to grab
+
+Open **Settings → LastCode → Appearance** and enable **Larger scrollbars**. Two controls appear:
+
+- **Scrollbar width** sets the visible thumb from 1 px through 12 px in one-pixel increments.
+- **Scrollbar margin** adds 0 px through 6 px of clear space between the thumb and the pane edge.
+
+The default larger-scrollbar profile uses a 10 px thumb and a 4 px margin. That margin keeps the
+thumb clear of the resize handle between adjacent panes. Changes apply immediately to native app
+scrollbars, styled scroll areas, and terminal scrollback, and are stored in the current LastCode
+profile. Turning the setting off restores the standard scrollbar appearance without discarding the
+chosen width and margin.
+
+LastCode desktop and Chromium-based web browsers apply the exact pixel values to native
+scrollbars. Firefox exposes only system scrollbar sizes: enabling the setting selects its larger
+system scrollbar, while the width and margin sliders continue to apply exactly to LastCode's
+styled scroll areas.
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index fd00e068bf21..306329928f88 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -76,6 +76,26 @@ describe("ClientSettings proactive panels", () => {
});
});
+describe("ClientSettings larger scrollbars", () => {
+ it("is opt-in with defaults that clear the pane resize target", () => {
+ const settings = decodeClientSettings({});
+
+ expect(settings.largerScrollbarsEnabled).toBe(false);
+ expect(settings.scrollbarWidth).toBe(10);
+ expect(settings.scrollbarMargin).toBe(4);
+ });
+
+ it.each([
+ ["scrollbarWidth", 1, 12],
+ ["scrollbarMargin", 0, 6],
+ ] as const)("accepts the inclusive %s range", (key, minimum, maximum) => {
+ expect(decodeClientSettingsPatch({ [key]: minimum })).toEqual({ [key]: minimum });
+ expect(decodeClientSettingsPatch({ [key]: maximum })).toEqual({ [key]: maximum });
+ expect(() => decodeClientSettingsPatch({ [key]: minimum - 1 })).toThrow();
+ expect(() => decodeClientSettingsPatch({ [key]: maximum + 1 })).toThrow();
+ });
+});
+
describe("ClientSettings quit confirmation", () => {
it("defaults to hold", () => {
expect(decodeClientSettings({}).confirmQuit).toBe("hold");
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index b93bc4743501..4dd899a371aa 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -104,6 +104,22 @@ export const AppearanceContrast = Schema.Int.check(
);
export type AppearanceContrast = typeof AppearanceContrast.Type;
export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100;
+export const MIN_SCROLLBAR_WIDTH = 1;
+export const MAX_SCROLLBAR_WIDTH = 12;
+export const ScrollbarWidth = Schema.Int.check(
+ Schema.isBetween({ minimum: MIN_SCROLLBAR_WIDTH, maximum: MAX_SCROLLBAR_WIDTH }),
+);
+export type ScrollbarWidth = typeof ScrollbarWidth.Type;
+export const DEFAULT_SCROLLBAR_WIDTH: ScrollbarWidth = 10;
+export const MIN_SCROLLBAR_MARGIN = 0;
+export const MAX_SCROLLBAR_MARGIN = 6;
+export const ScrollbarMargin = Schema.Int.check(
+ Schema.isBetween({ minimum: MIN_SCROLLBAR_MARGIN, maximum: MAX_SCROLLBAR_MARGIN }),
+);
+export type ScrollbarMargin = typeof ScrollbarMargin.Type;
+// The inline preview resize handle reaches four pixels into its neighboring
+// pane, so this default keeps the whole scrollbar thumb clear of that target.
+export const DEFAULT_SCROLLBAR_MARGIN: ScrollbarMargin = 4;
export const MIN_PANEL_ANIMATION_DURATION_MS = 0;
export const MAX_PANEL_ANIMATION_DURATION_MS = 400;
export const PanelAnimationDurationMs = Schema.Int.check(
@@ -357,6 +373,13 @@ export const ClientSettingsSchema = Schema.Struct({
legacySidebarScale: LegacySidebarScale.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_LEGACY_SIDEBAR_SCALE)),
),
+ largerScrollbarsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+ scrollbarWidth: ScrollbarWidth.pipe(
+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_SCROLLBAR_WIDTH)),
+ ),
+ scrollbarMargin: ScrollbarMargin.pipe(
+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_SCROLLBAR_MARGIN)),
+ ),
roundedProjectIcons: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
showLocalEnvironmentIcon: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
@@ -1205,6 +1228,9 @@ export const ClientSettingsPatch = Schema.Struct({
showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
legacySidebarScale: Schema.optionalKey(LegacySidebarScale),
+ largerScrollbarsEnabled: Schema.optionalKey(Schema.Boolean),
+ scrollbarWidth: Schema.optionalKey(ScrollbarWidth),
+ scrollbarMargin: Schema.optionalKey(ScrollbarMargin),
roundedProjectIcons: Schema.optionalKey(Schema.Boolean),
showLocalEnvironmentIcon: Schema.optionalKey(Schema.Boolean),
sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),