authorgravatar for 126423069+Marve10s@users.noreply.github.comIbrahim Elkamali <126423069+Marve10s@users.noreply.github.com> 2026-05-08 03:12:59+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-07 17:12:59-07:00
log466d8ee5b0a003b3f735dd25e21273539b515316
tree7eac4914d8a5fdc0506875d1504bd3466afa7a75
parent11f40556de8a37c7f16486c23fb6bce058275557
signature Signed by PGP key B5690EEEBB952194

feat(web): add configurable sidebar thread preview count (#1856)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <jmarminge@gmail.com>

11 files changed, 308 insertions(+), 33 deletions(-)

.plans/19-remote-endpoints-hosted-static.md-1
...@@ -347,4 +347,3 @@ Each implementation PR should run:...@@ -347,4 +347,3 @@ Each implementation PR should run:
347- `bun typecheck`347- `bun typecheck`
348- focused tests for changed backend/web behavior348- focused tests for changed backend/web behavior
349- backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test`349- backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test`
350
apps/desktop/src/clientPersistence.test.ts+1
...@@ -63,6 +63,7 @@ const clientSettings: ClientSettings = {...@@ -63,6 +63,7 @@ const clientSettings: ClientSettings = {
63 },63 },
64 sidebarProjectSortOrder: "manual",64 sidebarProjectSortOrder: "manual",
65 sidebarThreadSortOrder: "created_at",65 sidebarThreadSortOrder: "created_at",
66 sidebarThreadPreviewCount: 6,
66 timestampFormat: "24-hour",67 timestampFormat: "24-hour",
67};68};
6869
apps/web/package.json+1-1
...@@ -13,7 +13,7 @@...@@ -13,7 +13,7 @@
13 "test:browser:install": "playwright install --with-deps chromium"13 "test:browser:install": "playwright install --with-deps chromium"
14 },14 },
15 "dependencies": {15 "dependencies": {
16 "@base-ui/react": "^1.2.0",16 "@base-ui/react": "^1.4.1",
17 "@dnd-kit/core": "^6.3.1",17 "@dnd-kit/core": "^6.3.1",
18 "@dnd-kit/modifiers": "^9.0.0",18 "@dnd-kit/modifiers": "^9.0.0",
19 "@dnd-kit/sortable": "^10.0.0",19 "@dnd-kit/sortable": "^10.0.0",
apps/web/src/components/Sidebar.tsx+94-7
...@@ -54,7 +54,10 @@ import {...@@ -54,7 +54,10 @@ import {
54} from "@t3tools/client-runtime";54} from "@t3tools/client-runtime";
55import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";55import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router";
56import {56import {
57 MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
58 MIN_SIDEBAR_THREAD_PREVIEW_COUNT,
57 type SidebarProjectSortOrder,59 type SidebarProjectSortOrder,
60 type SidebarThreadPreviewCount,
58 type SidebarThreadSortOrder,61 type SidebarThreadSortOrder,
59} from "@t3tools/contracts/settings";62} from "@t3tools/contracts/settings";
60import { usePrimaryEnvironmentId } from "../environments/primary";63import { usePrimaryEnvironmentId } from "../environments/primary";
...@@ -128,6 +131,13 @@ import {...@@ -128,6 +131,13 @@ import {
128 MenuSeparator,131 MenuSeparator,
129 MenuTrigger,132 MenuTrigger,
130} from "./ui/menu";133} from "./ui/menu";
134import {
135 NumberField,
136 NumberFieldDecrement,
137 NumberFieldGroup,
138 NumberFieldIncrement,
139 NumberFieldInput,
140} from "./ui/number-field";
131import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";141import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
132import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";142import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
133import {143import {
...@@ -186,7 +196,6 @@ import {...@@ -186,7 +196,6 @@ import {
186 type SidebarProjectSnapshot,196 type SidebarProjectSnapshot,
187} from "../sidebarProjectGrouping";197} from "../sidebarProjectGrouping";
188import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";198import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill";
189const THREAD_PREVIEW_LIMIT = 6;
190const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {199const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
191 updated_at: "Last user message",200 updated_at: "Last user message",
192 created_at: "Created at",201 created_at: "Created at",
...@@ -207,6 +216,13 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> =...@@ -207,6 +216,13 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> =
207 separate: "Keep separate",216 separate: "Keep separate",
208};217};
209218
219function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount {
220 return Math.min(
221 MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
222 Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value),
223 ) as SidebarThreadPreviewCount;
224}
225
210function formatProjectMemberActionLabel(226function formatProjectMemberActionLabel(
211 member: SidebarProjectGroupMember,227 member: SidebarProjectGroupMember,
212 groupedProjectCount: number,228 groupedProjectCount: number,
...@@ -936,6 +952,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -936,6 +952,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
936 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,952 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
937 }));953 }));
938 const { updateSettings } = useUpdateSettings();954 const { updateSettings } = useUpdateSettings();
955 const sidebarThreadPreviewCount = useSettings<SidebarThreadPreviewCount>(
956 (settings) => settings.sidebarThreadPreviewCount,
957 );
939 const router = useRouter();958 const router = useRouter();
940 const { isMobile, setOpenMobile } = useSidebar();959 const { isMobile, setOpenMobile } = useSidebar();
941 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);960 const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
...@@ -1159,11 +1178,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1159,11 +1178,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1159 },1178 },
1160 });1179 });
1161 };1180 };
1162 const hasOverflowingThreads = visibleProjectThreads.length > THREAD_PREVIEW_LIMIT;1181 const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount;
1163 const previewThreads =1182 const previewThreads =
1164 isThreadListExpanded || !hasOverflowingThreads1183 isThreadListExpanded || !hasOverflowingThreads
1165 ? visibleProjectThreads1184 ? visibleProjectThreads
1166 : visibleProjectThreads.slice(0, THREAD_PREVIEW_LIMIT);1185 : visibleProjectThreads.slice(0, sidebarThreadPreviewCount);
1167 const visibleThreadKeys = new Set(1186 const visibleThreadKeys = new Set(
1168 [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) =>1187 [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) =>
1169 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),1188 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
...@@ -1192,6 +1211,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1192,6 +1211,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1192 pinnedCollapsedThread,1211 pinnedCollapsedThread,
1193 projectExpanded,1212 projectExpanded,
1194 projectThreads,1213 projectThreads,
1214 sidebarThreadPreviewCount,
1195 threadLastVisitedAts,1215 threadLastVisitedAts,
1196 visibleProjectThreads,1216 visibleProjectThreads,
1197 ]);1217 ]);
...@@ -2253,17 +2273,35 @@ function ProjectSortMenu({...@@ -2253,17 +2273,35 @@ function ProjectSortMenu({
2253 projectSortOrder,2273 projectSortOrder,
2254 threadSortOrder,2274 threadSortOrder,
2255 projectGroupingMode,2275 projectGroupingMode,
2276 threadPreviewCount,
2256 onProjectSortOrderChange,2277 onProjectSortOrderChange,
2257 onThreadSortOrderChange,2278 onThreadSortOrderChange,
2258 onProjectGroupingModeChange,2279 onProjectGroupingModeChange,
2280 onThreadPreviewCountChange,
2259}: {2281}: {
2260 projectSortOrder: SidebarProjectSortOrder;2282 projectSortOrder: SidebarProjectSortOrder;
2261 threadSortOrder: SidebarThreadSortOrder;2283 threadSortOrder: SidebarThreadSortOrder;
2262 projectGroupingMode: SidebarProjectGroupingMode;2284 projectGroupingMode: SidebarProjectGroupingMode;
2285 threadPreviewCount: SidebarThreadPreviewCount;
2263 onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;2286 onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void;
2264 onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;2287 onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
2265 onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;2288 onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void;
2289 onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void;
2266}) {2290}) {
2291 const handleThreadPreviewCountChange = useCallback(
2292 (nextValue: number | null) => {
2293 if (nextValue === null) {
2294 return;
2295 }
2296
2297 const clampedValue = clampSidebarThreadPreviewCount(nextValue);
2298 if (clampedValue !== threadPreviewCount) {
2299 onThreadPreviewCountChange(clampedValue);
2300 }
2301 },
2302 [onThreadPreviewCountChange, threadPreviewCount],
2303 );
2304
2267 return (2305 return (
2268 <Menu>2306 <Menu>
2269 <Tooltip>2307 <Tooltip>
...@@ -2274,9 +2312,9 @@ function ProjectSortMenu({...@@ -2274,9 +2312,9 @@ function ProjectSortMenu({
2274 >2312 >
2275 <ArrowUpDownIcon className="size-3.5" />2313 <ArrowUpDownIcon className="size-3.5" />
2276 </TooltipTrigger>2314 </TooltipTrigger>
2277 <TooltipPopup side="right">Sort projects</TooltipPopup>2315 <TooltipPopup side="right">Sidebar options</TooltipPopup>
2278 </Tooltip>2316 </Tooltip>
2279 <MenuPopup align="end" side="bottom" className="min-w-44">2317 <MenuPopup align="end" side="bottom" className="min-w-52">
2280 <MenuGroup>2318 <MenuGroup>
2281 <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground">2319 <div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground">
2282 Sort projects2320 Sort projects
...@@ -2315,6 +2353,42 @@ function ProjectSortMenu({...@@ -2315,6 +2353,42 @@ function ProjectSortMenu({
2315 ))}2353 ))}
2316 </MenuRadioGroup>2354 </MenuRadioGroup>
2317 </MenuGroup>2355 </MenuGroup>
2356 <MenuGroup>
2357 <div className="px-2 pt-2 pb-1 text-muted-foreground sm:text-xs font-medium">
2358 Visible threads
2359 </div>
2360 <div className="px-2 py-1">
2361 <NumberField
2362 aria-label="Visible thread count"
2363 className="w-28 gap-0"
2364 max={MAX_SIDEBAR_THREAD_PREVIEW_COUNT}
2365 min={MIN_SIDEBAR_THREAD_PREVIEW_COUNT}
2366 onValueChange={handleThreadPreviewCountChange}
2367 size="sm"
2368 step={1}
2369 value={threadPreviewCount}
2370 >
2371 <NumberFieldGroup className="h-7 rounded-md sm:h-6.5">
2372 <NumberFieldDecrement
2373 aria-label="Decrease visible thread count"
2374 className="px-2 sm:px-2 [&_svg]:size-3.5"
2375 />
2376 <NumberFieldInput
2377 aria-label="Visible thread count"
2378 className="h-7 w-9 grow-0 px-0 text-xs leading-7 sm:h-6.5 sm:leading-6.5"
2379 inputMode="numeric"
2380 onKeyDownCapture={(event) => {
2381 event.stopPropagation();
2382 }}
2383 />
2384 <NumberFieldIncrement
2385 aria-label="Increase visible thread count"
2386 className="px-2 sm:px-2 [&_svg]:size-3.5"
2387 />
2388 </NumberFieldGroup>
2389 </NumberField>
2390 </div>
2391 </MenuGroup>
2318 <MenuSeparator />2392 <MenuSeparator />
2319 <MenuGroup>2393 <MenuGroup>
2320 <div className="px-2 pt-2 pb-1 font-medium text-muted-foreground sm:text-xs">2394 <div className="px-2 pt-2 pb-1 font-medium text-muted-foreground sm:text-xs">
...@@ -2462,6 +2536,7 @@ interface SidebarProjectsContentProps {...@@ -2462,6 +2536,7 @@ interface SidebarProjectsContentProps {
2462 projectSortOrder: SidebarProjectSortOrder;2536 projectSortOrder: SidebarProjectSortOrder;
2463 threadSortOrder: SidebarThreadSortOrder;2537 threadSortOrder: SidebarThreadSortOrder;
2464 projectGroupingMode: SidebarProjectGroupingMode;2538 projectGroupingMode: SidebarProjectGroupingMode;
2539 threadPreviewCount: SidebarThreadPreviewCount;
2465 updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"];2540 updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"];
2466 openAddProject: () => void;2541 openAddProject: () => void;
2467 isManualProjectSorting: boolean;2542 isManualProjectSorting: boolean;
...@@ -2502,6 +2577,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2502,6 +2577,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2502 projectSortOrder,2577 projectSortOrder,
2503 threadSortOrder,2578 threadSortOrder,
2504 projectGroupingMode,2579 projectGroupingMode,
2580 threadPreviewCount,
2505 updateSettings,2581 updateSettings,
2506 openAddProject,2582 openAddProject,
2507 isManualProjectSorting,2583 isManualProjectSorting,
...@@ -2548,6 +2624,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2548,6 +2624,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2548 },2624 },
2549 [updateSettings],2625 [updateSettings],
2550 );2626 );
2627 const handleThreadPreviewCountChange = useCallback(
2628 (count: SidebarThreadPreviewCount) => {
2629 updateSettings({ sidebarThreadPreviewCount: count });
2630 },
2631 [updateSettings],
2632 );
25512633
2552 return (2634 return (
2553 <SidebarContent className="gap-0">2635 <SidebarContent className="gap-0">
...@@ -2607,9 +2689,11 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2607,9 +2689,11 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2607 projectSortOrder={projectSortOrder}2689 projectSortOrder={projectSortOrder}
2608 threadSortOrder={threadSortOrder}2690 threadSortOrder={threadSortOrder}
2609 projectGroupingMode={projectGroupingMode}2691 projectGroupingMode={projectGroupingMode}
2692 threadPreviewCount={threadPreviewCount}
2610 onProjectSortOrderChange={handleProjectSortOrderChange}2693 onProjectSortOrderChange={handleProjectSortOrderChange}
2611 onThreadSortOrderChange={handleThreadSortOrderChange}2694 onThreadSortOrderChange={handleThreadSortOrderChange}
2612 onProjectGroupingModeChange={handleProjectGroupingModeChange}2695 onProjectGroupingModeChange={handleProjectGroupingModeChange}
2696 onThreadPreviewCountChange={handleThreadPreviewCountChange}
2613 />2697 />
2614 <Tooltip>2698 <Tooltip>
2615 <TooltipTrigger2699 <TooltipTrigger
...@@ -2729,6 +2813,7 @@ export default function Sidebar() {...@@ -2729,6 +2813,7 @@ export default function Sidebar() {
2729 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,2813 sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
2730 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,2814 sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
2731 }));2815 }));
2816 const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount);
2732 const { updateSettings } = useUpdateSettings();2817 const { updateSettings } = useUpdateSettings();
2733 const { handleNewThread } = useNewThreadHandler();2818 const { handleNewThread } = useNewThreadHandler();
2734 const { archiveThread, deleteThread } = useThreadActions();2819 const { archiveThread, deleteThread } = useThreadActions();
...@@ -3024,11 +3109,11 @@ export default function Sidebar() {...@@ -3024,11 +3109,11 @@ export default function Sidebar() {
3024 return [];3109 return [];
3025 }3110 }
3026 const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey);3111 const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey);
3027 const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;3112 const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount;
3028 const previewThreads =3113 const previewThreads =
3029 isThreadListExpanded || !hasOverflowingThreads3114 isThreadListExpanded || !hasOverflowingThreads
3030 ? projectThreads3115 ? projectThreads
3031 : projectThreads.slice(0, THREAD_PREVIEW_LIMIT);3116 : projectThreads.slice(0, sidebarThreadPreviewCount);
3032 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads;3117 const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads;
3033 return renderedThreads.map((thread) =>3118 return renderedThreads.map((thread) =>
3034 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),3119 scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
...@@ -3036,6 +3121,7 @@ export default function Sidebar() {...@@ -3036,6 +3121,7 @@ export default function Sidebar() {
3036 }),3121 }),
3037 [3122 [
3038 sidebarThreadSortOrder,3123 sidebarThreadSortOrder,
3124 sidebarThreadPreviewCount,
3039 expandedThreadListsByProject,3125 expandedThreadListsByProject,
3040 projectExpandedById,3126 projectExpandedById,
3041 routeThreadKey,3127 routeThreadKey,
...@@ -3358,6 +3444,7 @@ export default function Sidebar() {...@@ -3358,6 +3444,7 @@ export default function Sidebar() {
3358 projectSortOrder={sidebarProjectSortOrder}3444 projectSortOrder={sidebarProjectSortOrder}
3359 threadSortOrder={sidebarThreadSortOrder}3445 threadSortOrder={sidebarThreadSortOrder}
3360 projectGroupingMode={sidebarProjectGroupingMode}3446 projectGroupingMode={sidebarProjectGroupingMode}
3447 threadPreviewCount={sidebarThreadPreviewCount}
3361 updateSettings={updateSettings}3448 updateSettings={updateSettings}
3362 openAddProject={openAddProjectCommandPalette}3449 openAddProject={openAddProjectCommandPalette}
3363 isManualProjectSorting={isManualProjectSorting}3450 isManualProjectSorting={isManualProjectSorting}
apps/web/src/components/settings/SettingsPanels.tsx+4
...@@ -364,6 +364,9 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -364,6 +364,9 @@ export function useSettingsRestore(onRestored?: () => void) {
364 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat364 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
365 ? ["Time format"]365 ? ["Time format"]
366 : []),366 : []),
367 ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount
368 ? ["Visible threads"]
369 : []),
367 ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap370 ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap
368 ? ["Diff line wrapping"]371 ? ["Diff line wrapping"]
369 : []),372 : []),
...@@ -400,6 +403,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -400,6 +403,7 @@ export function useSettingsRestore(onRestored?: () => void) {
400 settings.diffIgnoreWhitespace,403 settings.diffIgnoreWhitespace,
401 settings.diffWordWrap,404 settings.diffWordWrap,
402 settings.enableAssistantStreaming,405 settings.enableAssistantStreaming,
406 settings.sidebarThreadPreviewCount,
403 settings.timestampFormat,407 settings.timestampFormat,
404 theme,408 theme,
405 ],409 ],
apps/web/src/components/ui/input.tsx+26-15
...@@ -27,6 +27,31 @@ function Input({...@@ -27,6 +27,31 @@ function Input({
27 props.type === "file" &&27 props.type === "file" &&
28 "text-muted-foreground file:me-3 file:bg-transparent file:font-medium file:text-foreground file:text-sm",28 "text-muted-foreground file:me-3 file:bg-transparent file:font-medium file:text-foreground file:text-sm",
29 );29 );
30 let inputElement: React.ReactElement;
31
32 if (nativeInput) {
33 const { style, onValueChange: _onValueChange, ...nativeInputProps } = props;
34 const nativeStyle = typeof style === "function" ? undefined : style;
35
36 inputElement = (
37 <input
38 className={inputClassName}
39 data-slot="input"
40 size={typeof size === "number" ? size : undefined}
41 style={nativeStyle}
42 {...(nativeInputProps as React.ComponentProps<"input">)}
43 />
44 );
45 } else {
46 inputElement = (
47 <InputPrimitive
48 className={inputClassName}
49 data-slot="input"
50 size={typeof size === "number" ? size : undefined}
51 {...props}
52 />
53 );
54 }
3055
31 return (56 return (
32 <span57 <span
...@@ -40,21 +65,7 @@ function Input({...@@ -40,21 +65,7 @@ function Input({
40 data-size={size}65 data-size={size}
41 data-slot="input-control"66 data-slot="input-control"
42 >67 >
43 {nativeInput ? (68 {inputElement}
44 <input
45 className={inputClassName}
46 data-slot="input"
47 size={typeof size === "number" ? size : undefined}
48 {...props}
49 />
50 ) : (
51 <InputPrimitive
52 className={inputClassName}
53 data-slot="input"
54 size={typeof size === "number" ? size : undefined}
55 {...props}
56 />
57 )}
58 </span>69 </span>
59 );70 );
60}71}
apps/web/src/components/ui/number-field.tsx created+156
...@@ -0,0 +1,156 @@
1"use client";
2
3import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field";
4import { MinusIcon, PlusIcon } from "lucide-react";
5import * as React from "react";
6import { cn } from "~/lib/utils";
7import { Label } from "~/components/ui/label";
8
9export const NumberFieldContext: React.Context<{
10 fieldId: string;
11} | null> = React.createContext<{
12 fieldId: string;
13} | null>(null);
14
15export function NumberField({
16 id,
17 className,
18 size = "default",
19 ...props
20}: NumberFieldPrimitive.Root.Props & {
21 size?: "sm" | "default" | "lg";
22}): React.ReactElement {
23 const generatedId = React.useId();
24 const fieldId = id ?? generatedId;
25 const contextValue = React.useMemo(() => ({ fieldId }), [fieldId]);
26
27 return (
28 <NumberFieldContext.Provider value={contextValue}>
29 <NumberFieldPrimitive.Root
30 className={cn("flex w-full flex-col items-start gap-2", className)}
31 data-size={size}
32 data-slot="number-field"
33 id={fieldId}
34 {...props}
35 />
36 </NumberFieldContext.Provider>
37 );
38}
39
40export function NumberFieldGroup({
41 className,
42 ...props
43}: NumberFieldPrimitive.Group.Props): React.ReactElement {
44 return (
45 <NumberFieldPrimitive.Group
46 className={cn(
47 "relative flex w-full justify-between rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-within:not-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] focus-within:border-ring focus-within:ring-[3px] has-aria-invalid:border-destructive/36 has-autofill:bg-foreground/4 focus-within:has-aria-invalid:border-destructive/64 focus-within:has-aria-invalid:ring-destructive/48 data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24 dark:not-data-disabled:not-focus-within:not-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 [[data-disabled],:focus-within,[aria-invalid]]:shadow-none",
48 className,
49 )}
50 data-slot="number-field-group"
51 {...props}
52 />
53 );
54}
55
56export function NumberFieldDecrement({
57 className,
58 ...props
59}: NumberFieldPrimitive.Decrement.Props): React.ReactElement {
60 return (
61 <NumberFieldPrimitive.Decrement
62 className={cn(
63 "relative flex shrink-0 cursor-pointer items-center justify-center rounded-s-[calc(var(--radius-lg)-1px)] in-data-[size=sm]:px-[calc(--spacing(2.5)-1px)] px-[calc(--spacing(3)-1px)] transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
64 className,
65 )}
66 data-slot="number-field-decrement"
67 {...props}
68 >
69 <MinusIcon />
70 </NumberFieldPrimitive.Decrement>
71 );
72}
73
74export function NumberFieldIncrement({
75 className,
76 ...props
77}: NumberFieldPrimitive.Increment.Props): React.ReactElement {
78 return (
79 <NumberFieldPrimitive.Increment
80 className={cn(
81 "relative flex shrink-0 cursor-pointer items-center justify-center rounded-e-[calc(var(--radius-lg)-1px)] in-data-[size=sm]:px-[calc(--spacing(2.5)-1px)] px-[calc(--spacing(3)-1px)] transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
82 className,
83 )}
84 data-slot="number-field-increment"
85 {...props}
86 >
87 <PlusIcon />
88 </NumberFieldPrimitive.Increment>
89 );
90}
91
92export function NumberFieldInput({
93 className,
94 ...props
95}: NumberFieldPrimitive.Input.Props): React.ReactElement {
96 return (
97 <NumberFieldPrimitive.Input
98 className={cn(
99 "h-8.5 in-data-[size=lg]:h-9.5 in-data-[size=sm]:h-7.5 w-full min-w-0 grow bg-transparent in-data-[size=sm]:px-[calc(--spacing(2.5)-1px)] px-[calc(--spacing(3)-1px)] text-center tabular-nums in-data-[size=lg]:leading-9.5 in-data-[size=sm]:leading-7.5 leading-8.5 outline-none [transition:background-color_5000000s_ease-in-out_0s] sm:h-7.5 sm:in-data-[size=lg]:h-8.5 sm:in-data-[size=sm]:h-6.5 sm:in-data-[size=lg]:leading-8.5 sm:in-data-[size=sm]:leading-8.5 sm:leading-7.5",
100 className,
101 )}
102 data-slot="number-field-input"
103 {...props}
104 />
105 );
106}
107
108export function NumberFieldScrubArea({
109 className,
110 label,
111 ...props
112}: NumberFieldPrimitive.ScrubArea.Props & {
113 label: string;
114}): React.ReactElement {
115 const context = React.useContext(NumberFieldContext);
116
117 if (!context) {
118 throw new Error(
119 "NumberFieldScrubArea must be used within a NumberField component for accessibility.",
120 );
121 }
122
123 return (
124 <NumberFieldPrimitive.ScrubArea
125 className={cn("flex cursor-ew-resize", className)}
126 data-slot="number-field-scrub-area"
127 {...props}
128 >
129 <Label className="cursor-ew-resize" htmlFor={context.fieldId}>
130 {label}
131 </Label>
132 <NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
133 <CursorGrowIcon />
134 </NumberFieldPrimitive.ScrubAreaCursor>
135 </NumberFieldPrimitive.ScrubArea>
136 );
137}
138
139export function CursorGrowIcon(props: React.ComponentProps<"svg">): React.ReactElement {
140 return (
141 <svg
142 aria-hidden="true"
143 fill="black"
144 height="14"
145 stroke="white"
146 viewBox="0 0 24 14"
147 width="26"
148 xmlns="http://www.w3.org/2000/svg"
149 {...props}
150 >
151 <path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
152 </svg>
153 );
154}
155
156export { NumberFieldPrimitive };
apps/web/src/localApi.test.ts+2
...@@ -613,6 +613,7 @@ describe("wsApi", () => {...@@ -613,6 +613,7 @@ describe("wsApi", () => {
613 },613 },
614 sidebarProjectSortOrder: "manual" as const,614 sidebarProjectSortOrder: "manual" as const,
615 sidebarThreadSortOrder: "created_at" as const,615 sidebarThreadSortOrder: "created_at" as const,
616 sidebarThreadPreviewCount: 6,
616 timestampFormat: "24-hour" as const,617 timestampFormat: "24-hour" as const,
617 };618 };
618 const getClientSettings = vi.fn().mockResolvedValue({619 const getClientSettings = vi.fn().mockResolvedValue({
...@@ -675,6 +676,7 @@ describe("wsApi", () => {...@@ -675,6 +676,7 @@ describe("wsApi", () => {
675 },676 },
676 sidebarProjectSortOrder: "manual" as const,677 sidebarProjectSortOrder: "manual" as const,
677 sidebarThreadSortOrder: "created_at" as const,678 sidebarThreadSortOrder: "created_at" as const,
679 sidebarThreadPreviewCount: 6,
678 timestampFormat: "24-hour" as const,680 timestampFormat: "24-hour" as const,
679 };681 };
680682
apps/web/vite.config.ts-2
...@@ -7,7 +7,6 @@ import pkg from "./package.json" with { type: "json" };...@@ -7,7 +7,6 @@ import pkg from "./package.json" with { type: "json" };
77
8const port = Number(process.env.PORT ?? 5733);8const port = Number(process.env.PORT ?? 5733);
9const host = process.env.HOST?.trim() || "localhost";9const host = process.env.HOST?.trim() || "localhost";
10const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim();
11const configuredWsUrl = process.env.VITE_WS_URL?.trim();10const configuredWsUrl = process.env.VITE_WS_URL?.trim();
12const configuredHostedAppUrl = (() => {11const configuredHostedAppUrl = (() => {
13 if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) {12 if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) {
...@@ -74,7 +73,6 @@ export default defineConfig({...@@ -74,7 +73,6 @@ export default defineConfig({
74 ],73 ],
75 },74 },
76 define: {75 define: {
77 "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""),
78 // In dev mode, tell the web app where the WebSocket server lives76 // In dev mode, tell the web app where the WebSocket server lives
79 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),77 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),
80 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),78 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
bun.lock+11-7
...@@ -16,7 +16,7 @@...@@ -16,7 +16,7 @@
16 },16 },
17 "apps/desktop": {17 "apps/desktop": {
18 "name": "@t3tools/desktop",18 "name": "@t3tools/desktop",
19 "version": "0.0.21",19 "version": "0.0.22",
20 "dependencies": {20 "dependencies": {
21 "@effect/platform-node": "catalog:",21 "@effect/platform-node": "catalog:",
22 "effect": "catalog:",22 "effect": "catalog:",
...@@ -49,7 +49,7 @@...@@ -49,7 +49,7 @@
49 },49 },
50 "apps/server": {50 "apps/server": {
51 "name": "t3",51 "name": "t3",
52 "version": "0.0.21",52 "version": "0.0.22",
53 "bin": {53 "bin": {
54 "t3": "./dist/bin.mjs",54 "t3": "./dist/bin.mjs",
55 },55 },
...@@ -82,9 +82,9 @@...@@ -82,9 +82,9 @@
82 },82 },
83 "apps/web": {83 "apps/web": {
84 "name": "@t3tools/web",84 "name": "@t3tools/web",
85 "version": "0.0.21",85 "version": "0.0.22",
86 "dependencies": {86 "dependencies": {
87 "@base-ui/react": "^1.2.0",87 "@base-ui/react": "^1.4.1",
88 "@dnd-kit/core": "^6.3.1",88 "@dnd-kit/core": "^6.3.1",
89 "@dnd-kit/modifiers": "^9.0.0",89 "@dnd-kit/modifiers": "^9.0.0",
90 "@dnd-kit/sortable": "^10.0.0",90 "@dnd-kit/sortable": "^10.0.0",
...@@ -148,7 +148,7 @@...@@ -148,7 +148,7 @@
148 },148 },
149 "packages/contracts": {149 "packages/contracts": {
150 "name": "@t3tools/contracts",150 "name": "@t3tools/contracts",
151 "version": "0.0.21",151 "version": "0.0.22",
152 "dependencies": {152 "dependencies": {
153 "effect": "catalog:",153 "effect": "catalog:",
154 },154 },
...@@ -350,9 +350,9 @@...@@ -350,9 +350,9 @@
350350
351 "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],351 "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
352352
353 "@base-ui/react": ["@base-ui/react@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.6", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA=="],353 "@base-ui/react": ["@base-ui/react@1.4.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.2.8", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw=="],
354354
355 "@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="],355 "@base-ui/utils": ["@base-ui/utils@0.2.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ=="],
356356
357 "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="],357 "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="],
358358
...@@ -2122,6 +2122,10 @@...@@ -2122,6 +2122,10 @@
21222122
2123 "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],2123 "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
21242124
2125 "@base-ui/react/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
2126
2127 "@base-ui/utils/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
2128
2125 "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],2129 "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
21262130
2127 "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],2131 "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
packages/contracts/src/settings.ts+13
...@@ -27,6 +27,16 @@ export const SidebarProjectGroupingMode = Schema.Literals([...@@ -27,6 +27,16 @@ export const SidebarProjectGroupingMode = Schema.Literals([
27]);27]);
28export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type;28export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type;
29export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository";29export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository";
30export const MIN_SIDEBAR_THREAD_PREVIEW_COUNT = 1;
31export const MAX_SIDEBAR_THREAD_PREVIEW_COUNT = 15;
32export const SidebarThreadPreviewCount = Schema.Int.check(
33 Schema.isBetween({
34 minimum: MIN_SIDEBAR_THREAD_PREVIEW_COUNT,
35 maximum: MAX_SIDEBAR_THREAD_PREVIEW_COUNT,
36 }),
37);
38export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type;
39export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6;
3040
31export const ClientSettingsSchema = Schema.Struct({41export const ClientSettingsSchema = Schema.Struct({
32 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),42 autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
...@@ -75,6 +85,9 @@ export const ClientSettingsSchema = Schema.Struct({...@@ -75,6 +85,9 @@ export const ClientSettingsSchema = Schema.Struct({
75 sidebarThreadSortOrder: SidebarThreadSortOrder.pipe(85 sidebarThreadSortOrder: SidebarThreadSortOrder.pipe(
76 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)),86 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)),
77 ),87 ),
88 sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe(
89 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),
90 ),
78 timestampFormat: TimestampFormat.pipe(91 timestampFormat: TimestampFormat.pipe(
79 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),92 Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
80 ),93 ),