diff --git a/.plans/19-remote-endpoints-hosted-static.md b/.plans/19-remote-endpoints-hosted-static.md index 2fa0bc70211..ada2f681ce4 100644 --- a/.plans/19-remote-endpoints-hosted-static.md +++ b/.plans/19-remote-endpoints-hosted-static.md @@ -347,4 +347,3 @@ Each implementation PR should run: - `bun typecheck` - focused tests for changed backend/web behavior - backend tests for any server-side endpoint discovery or auth changes using `bun run test`, never `bun test` - diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index f0c7c30e202..0b959d709fd 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -63,6 +63,7 @@ const clientSettings: ClientSettings = { }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }; diff --git a/apps/web/package.json b/apps/web/package.json index 7fa8818109b..b61d529ab1c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,7 +13,7 @@ "test:browser:install": "playwright install --with-deps chromium" }, "dependencies": { - "@base-ui/react": "^1.2.0", + "@base-ui/react": "^1.4.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ccac1419437..b14bc5b9d2b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -54,7 +54,10 @@ import { } from "@t3tools/client-runtime"; import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { + MAX_SIDEBAR_THREAD_PREVIEW_COUNT, + MIN_SIDEBAR_THREAD_PREVIEW_COUNT, type SidebarProjectSortOrder, + type SidebarThreadPreviewCount, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { usePrimaryEnvironmentId } from "../environments/primary"; @@ -128,6 +131,13 @@ import { MenuSeparator, MenuTrigger, } from "./ui/menu"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "./ui/number-field"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { @@ -186,7 +196,6 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; -const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -207,6 +216,13 @@ const PROJECT_GROUPING_MODE_LABELS: Record = separate: "Keep separate", }; +function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { + return Math.min( + MAX_SIDEBAR_THREAD_PREVIEW_COUNT, + Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value), + ) as SidebarThreadPreviewCount; +} + function formatProjectMemberActionLabel( member: SidebarProjectGroupMember, groupedProjectCount: number, @@ -936,6 +952,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides, })); const { updateSettings } = useUpdateSettings(); + const sidebarThreadPreviewCount = useSettings( + (settings) => settings.sidebarThreadPreviewCount, + ); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); @@ -1159,11 +1178,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); }; - const hasOverflowingThreads = visibleProjectThreads.length > THREAD_PREVIEW_LIMIT; + const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount; const previewThreads = isThreadListExpanded || !hasOverflowingThreads ? visibleProjectThreads - : visibleProjectThreads.slice(0, THREAD_PREVIEW_LIMIT); + : visibleProjectThreads.slice(0, sidebarThreadPreviewCount); const visibleThreadKeys = new Set( [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), @@ -1192,6 +1211,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec pinnedCollapsedThread, projectExpanded, projectThreads, + sidebarThreadPreviewCount, threadLastVisitedAts, visibleProjectThreads, ]); @@ -2253,17 +2273,35 @@ function ProjectSortMenu({ projectSortOrder, threadSortOrder, projectGroupingMode, + threadPreviewCount, onProjectSortOrderChange, onThreadSortOrderChange, onProjectGroupingModeChange, + onThreadPreviewCountChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; projectGroupingMode: SidebarProjectGroupingMode; + threadPreviewCount: SidebarThreadPreviewCount; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; + onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; }) { + const handleThreadPreviewCountChange = useCallback( + (nextValue: number | null) => { + if (nextValue === null) { + return; + } + + const clampedValue = clampSidebarThreadPreviewCount(nextValue); + if (clampedValue !== threadPreviewCount) { + onThreadPreviewCountChange(clampedValue); + } + }, + [onThreadPreviewCountChange, threadPreviewCount], + ); + return ( @@ -2274,9 +2312,9 @@ function ProjectSortMenu({ > - Sort projects + Sidebar options - +
Sort projects @@ -2315,6 +2353,42 @@ function ProjectSortMenu({ ))} + +
+ Visible threads +
+
+ + + + { + event.stopPropagation(); + }} + /> + + + +
+
@@ -2462,6 +2536,7 @@ interface SidebarProjectsContentProps { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; projectGroupingMode: SidebarProjectGroupingMode; + threadPreviewCount: SidebarThreadPreviewCount; updateSettings: ReturnType["updateSettings"]; openAddProject: () => void; isManualProjectSorting: boolean; @@ -2502,6 +2577,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( projectSortOrder, threadSortOrder, projectGroupingMode, + threadPreviewCount, updateSettings, openAddProject, isManualProjectSorting, @@ -2548,6 +2624,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const handleThreadPreviewCountChange = useCallback( + (count: SidebarThreadPreviewCount) => { + updateSettings({ sidebarThreadPreviewCount: count }); + }, + [updateSettings], + ); return ( @@ -2607,9 +2689,11 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( projectSortOrder={projectSortOrder} threadSortOrder={threadSortOrder} projectGroupingMode={projectGroupingMode} + threadPreviewCount={threadPreviewCount} onProjectSortOrderChange={handleProjectSortOrderChange} onThreadSortOrderChange={handleThreadSortOrderChange} onProjectGroupingModeChange={handleProjectGroupingModeChange} + onThreadPreviewCountChange={handleThreadPreviewCountChange} /> s.sidebarThreadPreviewCount); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -3024,11 +3109,11 @@ export default function Sidebar() { return []; } const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; const previewThreads = isThreadListExpanded || !hasOverflowingThreads ? projectThreads - : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); + : projectThreads.slice(0, sidebarThreadPreviewCount); const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; return renderedThreads.map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), @@ -3036,6 +3121,7 @@ export default function Sidebar() { }), [ sidebarThreadSortOrder, + sidebarThreadPreviewCount, expandedThreadListsByProject, projectExpandedById, routeThreadKey, @@ -3358,6 +3444,7 @@ export default function Sidebar() { projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} projectGroupingMode={sidebarProjectGroupingMode} + threadPreviewCount={sidebarThreadPreviewCount} updateSettings={updateSettings} openAddProject={openAddProjectCommandPalette} isManualProjectSorting={isManualProjectSorting} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ee75fba5d06..99287438ab3 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -364,6 +364,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), + ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount + ? ["Visible threads"] + : []), ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ["Diff line wrapping"] : []), @@ -400,6 +403,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.diffIgnoreWhitespace, settings.diffWordWrap, settings.enableAssistantStreaming, + settings.sidebarThreadPreviewCount, settings.timestampFormat, theme, ], diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 001a240ddfd..7ab7eb6cea0 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -27,6 +27,31 @@ function Input({ props.type === "file" && "text-muted-foreground file:me-3 file:bg-transparent file:font-medium file:text-foreground file:text-sm", ); + let inputElement: React.ReactElement; + + if (nativeInput) { + const { style, onValueChange: _onValueChange, ...nativeInputProps } = props; + const nativeStyle = typeof style === "function" ? undefined : style; + + inputElement = ( + )} + /> + ); + } else { + inputElement = ( + + ); + } return ( - {nativeInput ? ( - - ) : ( - - )} + {inputElement} ); } diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx new file mode 100644 index 00000000000..2d627757d65 --- /dev/null +++ b/apps/web/src/components/ui/number-field.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"; +import { MinusIcon, PlusIcon } from "lucide-react"; +import * as React from "react"; +import { cn } from "~/lib/utils"; +import { Label } from "~/components/ui/label"; + +export const NumberFieldContext: React.Context<{ + fieldId: string; +} | null> = React.createContext<{ + fieldId: string; +} | null>(null); + +export function NumberField({ + id, + className, + size = "default", + ...props +}: NumberFieldPrimitive.Root.Props & { + size?: "sm" | "default" | "lg"; +}): React.ReactElement { + const generatedId = React.useId(); + const fieldId = id ?? generatedId; + const contextValue = React.useMemo(() => ({ fieldId }), [fieldId]); + + return ( + + + + ); +} + +export function NumberFieldGroup({ + className, + ...props +}: NumberFieldPrimitive.Group.Props): React.ReactElement { + return ( + + ); +} + +export function NumberFieldDecrement({ + className, + ...props +}: NumberFieldPrimitive.Decrement.Props): React.ReactElement { + return ( + + + + ); +} + +export function NumberFieldIncrement({ + className, + ...props +}: NumberFieldPrimitive.Increment.Props): React.ReactElement { + return ( + + + + ); +} + +export function NumberFieldInput({ + className, + ...props +}: NumberFieldPrimitive.Input.Props): React.ReactElement { + return ( + + ); +} + +export function NumberFieldScrubArea({ + className, + label, + ...props +}: NumberFieldPrimitive.ScrubArea.Props & { + label: string; +}): React.ReactElement { + const context = React.useContext(NumberFieldContext); + + if (!context) { + throw new Error( + "NumberFieldScrubArea must be used within a NumberField component for accessibility.", + ); + } + + return ( + + + + + + + ); +} + +export function CursorGrowIcon(props: React.ComponentProps<"svg">): React.ReactElement { + return ( + + ); +} + +export { NumberFieldPrimitive }; diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index a4dd3b82567..8bfb0e599ad 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -613,6 +613,7 @@ describe("wsApi", () => { }, sidebarProjectSortOrder: "manual" as const, sidebarThreadSortOrder: "created_at" as const, + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour" as const, }; const getClientSettings = vi.fn().mockResolvedValue({ @@ -675,6 +676,7 @@ describe("wsApi", () => { }, sidebarProjectSortOrder: "manual" as const, sidebarThreadSortOrder: "created_at" as const, + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour" as const, }; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cea7ce71ec9..07162c8768d 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,7 +7,6 @@ import pkg from "./package.json" with { type: "json" }; const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; -const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const configuredHostedAppUrl = (() => { if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) { @@ -74,7 +73,6 @@ export default defineConfig({ ], }, define: { - "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), // In dev mode, tell the web app where the WebSocket server lives "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), diff --git a/bun.lock b/bun.lock index a87ac77094b..c1c52a5b837 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ }, "apps/desktop": { "name": "@t3tools/desktop", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -49,7 +49,7 @@ }, "apps/server": { "name": "t3", - "version": "0.0.21", + "version": "0.0.22", "bin": { "t3": "./dist/bin.mjs", }, @@ -82,9 +82,9 @@ }, "apps/web": { "name": "@t3tools/web", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { - "@base-ui/react": "^1.2.0", + "@base-ui/react": "^1.4.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -148,7 +148,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.21", + "version": "0.0.22", "dependencies": { "effect": "catalog:", }, @@ -350,9 +350,9 @@ "@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=="], - "@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=="], + "@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=="], - "@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=="], + "@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=="], "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], @@ -2122,6 +2122,10 @@ "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@base-ui/react/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@base-ui/utils/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@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=="], "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a4805494dfb..15f13e539ea 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -27,6 +27,16 @@ export const SidebarProjectGroupingMode = Schema.Literals([ ]); export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; +export const MIN_SIDEBAR_THREAD_PREVIEW_COUNT = 1; +export const MAX_SIDEBAR_THREAD_PREVIEW_COUNT = 15; +export const SidebarThreadPreviewCount = Schema.Int.check( + Schema.isBetween({ + minimum: MIN_SIDEBAR_THREAD_PREVIEW_COUNT, + maximum: MAX_SIDEBAR_THREAD_PREVIEW_COUNT, + }), +); +export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; +export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), @@ -75,6 +85,9 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), ), + sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), + ), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ),