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 cff71cf62a..475e26098c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChevronRightIcon, CloudIcon, GitPullRequestIcon, + MinusIcon, PlusIcon, SearchIcon, SettingsIcon, @@ -48,7 +49,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"; @@ -102,6 +106,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 { @@ -148,7 +153,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", @@ -164,6 +168,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, @@ -996,6 +1007,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); @@ -1216,11 +1230,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)), @@ -1249,6 +1263,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec pinnedCollapsedThread, projectExpanded, projectThreads, + sidebarThreadPreviewCount, threadLastVisitedAts, visibleProjectThreads, ]); @@ -1853,14 +1868,45 @@ 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 resolveThreadPreviewInputValue = useCallback( + (nextValue: string) => { + const parsedValue = Number.parseInt(nextValue, 10); + return Number.isInteger(parsedValue) + ? clampSidebarThreadPreviewCount(parsedValue) + : threadPreviewCount; + }, + [threadPreviewCount], + ); + + const commitThreadPreviewCount = useCallback( + (nextValue: string) => { + const clampedValue = resolveThreadPreviewInputValue(nextValue); + setThreadPreviewInput(String(clampedValue)); + if (clampedValue !== threadPreviewCount) { + onThreadPreviewCountChange(clampedValue); + } + }, + [onThreadPreviewCountChange, resolveThreadPreviewInputValue, threadPreviewCount], + ); + return ( @@ -1871,9 +1917,9 @@ function ProjectSortMenu({ > - Sort projects + Sidebar options - +
Sort projects @@ -1912,6 +1958,67 @@ 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); + } + }} + /> + +
+
); @@ -2029,6 +2136,7 @@ interface SidebarProjectsContentProps { handleDesktopUpdateButtonClick: () => void; projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; + threadPreviewCount: SidebarThreadPreviewCount; updateSettings: ReturnType["updateSettings"]; openAddProject: () => void; isManualProjectSorting: boolean; @@ -2068,6 +2176,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleDesktopUpdateButtonClick, projectSortOrder, threadSortOrder, + threadPreviewCount, updateSettings, openAddProject, isManualProjectSorting, @@ -2108,6 +2217,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const handleThreadPreviewCountChange = useCallback( + (count: SidebarThreadPreviewCount) => { + updateSettings({ sidebarThreadPreviewCount: count }); + }, + [updateSettings], + ); return ( @@ -2166,8 +2281,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const sidebarThreadPreviewCount = useSettings((s) => s.sidebarThreadPreviewCount); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -2603,11 +2721,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)), @@ -2615,6 +2733,7 @@ export default function Sidebar() { }), [ sidebarThreadSortOrder, + sidebarThreadPreviewCount, expandedThreadListsByProject, projectExpandedById, routeThreadKey, @@ -2978,6 +3097,7 @@ export default function Sidebar() { handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} + 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 1d297add3c..ceef47794b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -435,6 +435,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"] : []), @@ -465,6 +468,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 06b163137b..823156fbf6 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -534,6 +534,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); const setClientSettings = vi.fn().mockResolvedValue(undefined); @@ -562,6 +563,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); await api.persistence.getSavedEnvironmentRegistry(); @@ -580,6 +582,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); @@ -599,6 +602,7 @@ describe("wsApi", () => { diffWordWrap: true, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", }); await api.persistence.setSavedEnvironmentRegistry([ @@ -622,6 +626,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 fe432e098b..e24a89fa4d 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;