Skip to content
1 change: 1 addition & 0 deletions apps/desktop/src/clientPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const clientSettings: ClientSettings = {
confirmThreadDelete: false,
diffWordWrap: true,
sidebarProjectSortOrder: "manual",
sidebarThreadPreviewCount: 6,
sidebarThreadSortOrder: "created_at",
timestampFormat: "24-hour",
};
Expand Down
126 changes: 119 additions & 7 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CloudIcon,
FolderIcon,
GitPullRequestIcon,
MinusIcon,
PlusIcon,
SettingsIcon,
SquarePenIcon,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<SidebarProjectSortOrder, string> = {
updated_at: "Last user message",
created_at: "Created at",
Expand All @@ -161,6 +165,13 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
} as const;
const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>();

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<string, string>,
right: ReadonlyMap<string, string>,
Expand Down Expand Up @@ -977,6 +988,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
const defaultThreadEnvMode = useSettings<ThreadEnvMode>(
(settings) => settings.defaultThreadEnvMode,
);
const sidebarThreadPreviewCount = useSettings<SidebarThreadPreviewCount>(
(settings) => settings.sidebarThreadPreviewCount,
);
const router = useRouter();
const markThreadUnread = useUiStateStore((state) => state.markThreadUnread);
const toggleProject = useUiStateStore((state) => state.toggleProject);
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -1225,6 +1239,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
pinnedCollapsedThread,
projectExpanded,
projectThreads,
sidebarThreadPreviewCount,
threadLastVisitedAts,
visibleProjectThreads,
]);
Expand Down Expand Up @@ -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 (
<Menu>
<Tooltip>
Expand All @@ -1854,9 +1896,9 @@ function ProjectSortMenu({
>
<ArrowUpDownIcon className="size-3.5" />
</TooltipTrigger>
<TooltipPopup side="right">Sort projects</TooltipPopup>
<TooltipPopup side="right">Sidebar options</TooltipPopup>
</Tooltip>
<MenuPopup align="end" side="bottom" className="min-w-44">
<MenuPopup align="end" side="bottom" className="min-w-52">
<MenuGroup>
<div className="px-2 py-1 sm:text-xs font-medium text-muted-foreground">
Sort projects
Expand Down Expand Up @@ -1895,6 +1937,63 @@ function ProjectSortMenu({
))}
</MenuRadioGroup>
</MenuGroup>
<MenuGroup>
<div className="px-2 pt-2 pb-1 text-muted-foreground sm:text-xs font-medium">
Visible threads
</div>
<div className="flex items-center gap-2 px-2 py-1">
<Button
size="icon-xs"
variant="outline"
className="size-7 shrink-0"
aria-label="Decrease visible thread count"
disabled={threadPreviewCount <= MIN_SIDEBAR_THREAD_PREVIEW_COUNT}
onClick={() =>
onThreadPreviewCountChange(clampSidebarThreadPreviewCount(threadPreviewCount - 1))
}
>
<MinusIcon className="size-3.5" />
</Button>
<Input
nativeInput
type="text"
inputMode="numeric"
min={MIN_SIDEBAR_THREAD_PREVIEW_COUNT}
max={MAX_SIDEBAR_THREAD_PREVIEW_COUNT}
pattern="[0-9]*"
size="sm"
className="w-14"
aria-label="Visible thread count"
value={threadPreviewInput}
onKeyDownCapture={(event) => {
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);
}
}}
/>
<Button
size="icon-xs"
variant="outline"
className="size-7 shrink-0"
aria-label="Increase visible thread count"
disabled={threadPreviewCount >= MAX_SIDEBAR_THREAD_PREVIEW_COUNT}
onClick={() =>
onThreadPreviewCountChange(clampSidebarThreadPreviewCount(threadPreviewCount + 1))
}
>
<PlusIcon className="size-3.5" />
</Button>
</div>
</MenuGroup>
</MenuPopup>
</Menu>
);
Expand Down Expand Up @@ -2012,6 +2111,7 @@ interface SidebarProjectsContentProps {
handleDesktopUpdateButtonClick: () => void;
projectSortOrder: SidebarProjectSortOrder;
threadSortOrder: SidebarThreadSortOrder;
threadPreviewCount: SidebarThreadPreviewCount;
updateSettings: ReturnType<typeof useUpdateSettings>["updateSettings"];
shouldShowProjectPathEntry: boolean;
handleStartAddProject: () => void;
Expand Down Expand Up @@ -2063,6 +2163,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
handleDesktopUpdateButtonClick,
projectSortOrder,
threadSortOrder,
threadPreviewCount,
updateSettings,
shouldShowProjectPathEntry,
handleStartAddProject,
Expand Down Expand Up @@ -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<HTMLInputElement>) => {
setNewCwd(event.target.value);
Expand Down Expand Up @@ -2170,8 +2277,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
<ProjectSortMenu
projectSortOrder={projectSortOrder}
threadSortOrder={threadSortOrder}
threadPreviewCount={threadPreviewCount}
onProjectSortOrderChange={handleProjectSortOrderChange}
onThreadSortOrderChange={handleThreadSortOrderChange}
onThreadPreviewCountChange={handleThreadPreviewCountChange}
/>
<Tooltip>
<TooltipTrigger
Expand Down Expand Up @@ -2336,6 +2445,7 @@ export default function Sidebar() {
const isOnSettings = pathname.startsWith("/settings");
const sidebarThreadSortOrder = useSettings((s) => 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();
Expand Down Expand Up @@ -2790,18 +2900,19 @@ 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)),
);
}),
[
sidebarThreadSortOrder,
sidebarThreadPreviewCount,
expandedThreadListsByProject,
projectExpandedById,
routeThreadKey,
Expand Down Expand Up @@ -3136,6 +3247,7 @@ export default function Sidebar() {
handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}
projectSortOrder={sidebarProjectSortOrder}
threadSortOrder={sidebarThreadSortOrder}
threadPreviewCount={sidebarThreadPreviewCount}
updateSettings={updateSettings}
shouldShowProjectPathEntry={shouldShowProjectPathEntry}
handleStartAddProject={handleStartAddProject}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
: []),
Expand All @@ -394,6 +397,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.defaultThreadEnvMode,
settings.diffWordWrap,
settings.enableAssistantStreaming,
settings.sidebarThreadPreviewCount,
settings.timestampFormat,
theme,
],
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/localApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ describe("wsApi", () => {
diffWordWrap: true,
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
timestampFormat: "24-hour",
});
const setClientSettings = vi.fn().mockResolvedValue(undefined);
Expand Down Expand Up @@ -531,6 +532,7 @@ describe("wsApi", () => {
diffWordWrap: true,
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
timestampFormat: "24-hour",
});
await api.persistence.getSavedEnvironmentRegistry();
Expand All @@ -549,6 +551,7 @@ describe("wsApi", () => {
diffWordWrap: true,
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
timestampFormat: "24-hour",
});
expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith();
Expand All @@ -568,6 +571,7 @@ describe("wsApi", () => {
diffWordWrap: true,
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
timestampFormat: "24-hour",
});
await api.persistence.setSavedEnvironmentRegistry([
Expand All @@ -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([
Expand Down
14 changes: 14 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))),
Expand All @@ -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;

Expand Down
Loading