diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index df2178c0b0..88913ec0b5 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -53,6 +53,7 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, diffWordWrap: true, sidebarProjectSortOrder: "manual", + sidebarThreadPreviewCount: 6, sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", }; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bb63db6fc0..dfdeca2c08 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { CloudIcon, FolderIcon, GitPullRequestIcon, + MinusIcon, PlusIcon, SettingsIcon, SquarePenIcon, @@ -49,7 +50,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"; @@ -101,6 +105,7 @@ import { } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; +import { Input } from "./ui/input"; import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { @@ -145,7 +150,6 @@ import { useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import type { Project, SidebarThreadSummary } from "../types"; -const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -161,6 +165,13 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { } as const; const EMPTY_THREAD_JUMP_LABELS = new Map(); +function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { + return Math.min( + MAX_SIDEBAR_THREAD_PREVIEW_COUNT, + Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value), + ) as SidebarThreadPreviewCount; +} + function threadJumpLabelMapsEqual( left: ReadonlyMap, right: ReadonlyMap, @@ -977,6 +988,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const defaultThreadEnvMode = useSettings( (settings) => settings.defaultThreadEnvMode, ); + const sidebarThreadPreviewCount = useSettings( + (settings) => settings.sidebarThreadPreviewCount, + ); const router = useRouter(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const toggleProject = useUiStateStore((state) => state.toggleProject); @@ -1192,11 +1206,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)), @@ -1225,6 +1239,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec pinnedCollapsedThread, projectExpanded, projectThreads, + sidebarThreadPreviewCount, threadLastVisitedAts, visibleProjectThreads, ]); @@ -1836,14 +1851,41 @@ type SortableProjectHandleProps = Pick< function ProjectSortMenu({ projectSortOrder, threadSortOrder, + threadPreviewCount, onProjectSortOrderChange, onThreadSortOrderChange, + onThreadPreviewCountChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + threadPreviewCount: SidebarThreadPreviewCount; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; + onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; }) { + const [threadPreviewInput, setThreadPreviewInput] = useState(() => String(threadPreviewCount)); + + useEffect(() => { + setThreadPreviewInput(String(threadPreviewCount)); + }, [threadPreviewCount]); + + const commitThreadPreviewCount = useCallback( + (nextValue: string) => { + const parsedValue = Number.parseInt(nextValue, 10); + if (!Number.isInteger(parsedValue)) { + setThreadPreviewInput(String(threadPreviewCount)); + return; + } + + const clampedValue = clampSidebarThreadPreviewCount(parsedValue); + setThreadPreviewInput(String(clampedValue)); + if (clampedValue !== threadPreviewCount) { + onThreadPreviewCountChange(clampedValue); + } + }, + [onThreadPreviewCountChange, threadPreviewCount], + ); + return ( @@ -1854,9 +1896,9 @@ function ProjectSortMenu({ > - Sort projects + Sidebar options - +
Sort projects @@ -1895,6 +1937,63 @@ function ProjectSortMenu({ ))} + +
+ Visible threads +
+
+ + { + event.stopPropagation(); + }} + onChange={(event) => { + setThreadPreviewInput(event.currentTarget.value.replace(/[^0-9]/g, "")); + }} + onBlur={(event) => { + commitThreadPreviewCount(event.currentTarget.value); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + commitThreadPreviewCount(event.currentTarget.value); + } + }} + /> + +
+
); @@ -2012,6 +2111,7 @@ interface SidebarProjectsContentProps { handleDesktopUpdateButtonClick: () => void; projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + threadPreviewCount: SidebarThreadPreviewCount; updateSettings: ReturnType["updateSettings"]; shouldShowProjectPathEntry: boolean; handleStartAddProject: () => void; @@ -2063,6 +2163,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleDesktopUpdateButtonClick, projectSortOrder, threadSortOrder, + threadPreviewCount, updateSettings, shouldShowProjectPathEntry, handleStartAddProject, @@ -2115,6 +2216,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const handleThreadPreviewCountChange = useCallback( + (count: SidebarThreadPreviewCount) => { + updateSettings({ sidebarThreadPreviewCount: count }); + }, + [updateSettings], + ); const handleAddProjectInputChange = useCallback( (event: React.ChangeEvent) => { setNewCwd(event.target.value); @@ -2170,8 +2277,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount); const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); @@ -2790,11 +2900,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)), @@ -2802,6 +2912,7 @@ export default function Sidebar() { }), [ sidebarThreadSortOrder, + sidebarThreadPreviewCount, expandedThreadListsByProject, projectExpandedById, routeThreadKey, @@ -3136,6 +3247,7 @@ export default function Sidebar() { handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} + threadPreviewCount={sidebarThreadPreviewCount} updateSettings={updateSettings} shouldShowProjectPathEntry={shouldShowProjectPathEntry} handleStartAddProject={handleStartAddProject} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c8766f2643..b7eccbcc22 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -368,6 +368,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"] : []), @@ -394,6 +397,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, + settings.sidebarThreadPreviewCount, settings.timestampFormat, theme, ], diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 68047f4495..f4260920cb 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -503,6 +503,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); const setClientSettings = vi.fn().mockResolvedValue(undefined); @@ -531,6 +532,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); await api.persistence.getSavedEnvironmentRegistry(); @@ -549,6 +551,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); @@ -568,6 +571,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); await api.persistence.setSavedEnvironmentRegistry([ @@ -591,6 +595,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 426f8bee56..e2f6982dc8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -23,6 +23,17 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const MIN_SIDEBAR_THREAD_PREVIEW_COUNT = 2; +export const MAX_SIDEBAR_THREAD_PREVIEW_COUNT = 10; +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({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), @@ -36,6 +47,9 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), + ), }); export type ClientSettings = typeof ClientSettingsSchema.Type;