diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..a0895c528b9 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -27,6 +27,8 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, + stickyUserMessageCount: 2, + stickyUserMessageMaxLines: 3, timestampFormat: "24-hour", }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..0dd591c2054 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3574,6 +3574,8 @@ export default function ChatView(props: ChatViewProps) { markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} + stickyUserMessageCount={settings.stickyUserMessageCount} + stickyUserMessageMaxLines={settings.stickyUserMessageMaxLines} workspaceRoot={activeWorkspaceRoot} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} onIsAtEndChange={onIsAtEndChange} diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index e59938b4958..07e6c4f77a4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -68,6 +68,8 @@ function buildProps() { markdownCwd: undefined, resolvedTheme: "dark" as const, timestampFormat: "24-hour" as const, + stickyUserMessageCount: 0, + stickyUserMessageMaxLines: 2, workspaceRoot: undefined, onIsAtEndChange: vi.fn(), }; @@ -79,16 +81,27 @@ function buildLongUserMessageText(tail = "deep hidden detail only after expand") ).join("\n"); } -function buildUserTimelineEntry(text: string) { +function buildUserTimelineEntry( + text: string, + { + entryId = "entry-1", + messageId = "message-1", + createdAt = MESSAGE_CREATED_AT, + }: { + entryId?: string; + messageId?: string; + createdAt?: string; + } = {}, +) { return { - id: "entry-1", + id: entryId, kind: "message" as const, - createdAt: MESSAGE_CREATED_AT, + createdAt, message: { - id: "message-1" as never, + id: messageId as never, role: "user" as const, text, - createdAt: MESSAGE_CREATED_AT, + createdAt, streaming: false, }, }; @@ -205,6 +218,134 @@ describe("MessagesTimeline", () => { } }); + it("shows a sticky user message only after its source row is above the transcript viewport", async () => { + let sourceAboveViewport = true; + const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this.dataset.testid === "legend-list") { + return DOMRect.fromRect({ x: 0, y: 0, width: 640, height: 240 }); + } + if (this.dataset.messageId === "message-1") { + return sourceAboveViewport + ? DOMRect.fromRect({ x: 0, y: -80, width: 480, height: 60 }) + : DOMRect.fromRect({ x: 0, y: 80, width: 480, height: 60 }); + } + return originalGetBoundingClientRect.call(this); + }, + ); + + const screen = await render( + , + ); + + try { + await expect + .element(page.getByRole("button", { name: "Scroll to original user message" })) + .toBeVisible(); + + sourceAboveViewport = false; + document.querySelector("[data-testid='legend-list']")?.dispatchEvent(new Event("scroll")); + + await expect + .element(page.getByRole("button", { name: "Scroll to original user message" })) + .not.toBeInTheDocument(); + } finally { + await screen.unmount(); + } + }); + + it("scrolls back to the original user message when the sticky copy is clicked", async () => { + const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this.dataset.testid === "legend-list") { + return DOMRect.fromRect({ x: 0, y: 0, width: 640, height: 240 }); + } + if (this.dataset.messageId === "message-1") { + return DOMRect.fromRect({ x: 0, y: -80, width: 480, height: 60 }); + } + return originalGetBoundingClientRect.call(this); + }, + ); + const scrollIntoViewSpy = vi + .spyOn(HTMLElement.prototype, "scrollIntoView") + .mockImplementation(() => undefined); + + const screen = await render( + , + ); + + try { + const stickyButton = page.getByRole("button", { name: "Scroll to original user message" }); + await expect.element(stickyButton).toBeVisible(); + await stickyButton.click(); + + expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: "start", behavior: "smooth" }); + } finally { + await screen.unmount(); + } + }); + + it("shows sticky metadata only on the newest visible sticky user message", async () => { + const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this.dataset.testid === "legend-list") { + return DOMRect.fromRect({ x: 0, y: 0, width: 640, height: 240 }); + } + if (this.dataset.messageId === "message-1" || this.dataset.messageId === "message-2") { + return DOMRect.fromRect({ x: 0, y: -80, width: 480, height: 60 }); + } + return originalGetBoundingClientRect.call(this); + }, + ); + + const screen = await render( + , + ); + + try { + await vi.waitFor(() => { + expect(document.querySelector("[data-sticky-user-message-id='message-1']")).not.toBeNull(); + expect(document.querySelector("[data-sticky-user-message-id='message-2']")).not.toBeNull(); + + const metaRows = document.querySelectorAll("[data-sticky-user-message-meta='true']"); + expect(metaRows).toHaveLength(1); + expect( + metaRows[0] + ?.closest("[data-sticky-user-message-id]") + ?.getAttribute("data-sticky-user-message-id"), + ).toBe("message-2"); + }); + } finally { + await screen.unmount(); + } + }); + it("expands and re-collapses long user messages from the toggle", async () => { const screen = await render( {}, }; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1540d5f344a..f832755321b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -58,7 +58,16 @@ import { import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; +import type { + StickyUserMessageCount, + StickyUserMessageMaxLines, +} from "@t3tools/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; +import { + deriveStickyUserMessageEntries, + StickyUserMessagesOverlay, + useHiddenStickyUserMessageIds, +} from "./StickyUserMessagesOverlay"; import { buildInlineTerminalContextText, @@ -123,6 +132,8 @@ interface MessagesTimelineProps { markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; + stickyUserMessageCount: StickyUserMessageCount; + stickyUserMessageMaxLines: StickyUserMessageMaxLines; workspaceRoot: string | undefined; skills?: ReadonlyArray>; onIsAtEndChange: (isAtEnd: boolean) => void; @@ -152,6 +163,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ markdownCwd, resolvedTheme, timestampFormat, + stickyUserMessageCount, + stickyUserMessageMaxLines, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, onIsAtEndChange, @@ -182,6 +195,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ], ); const rows = useStableRows(rawRows); + const timelineViewportRef = useRef(null); + const stickyUserMessageEntries = useMemo( + () => deriveStickyUserMessageEntries(rows, stickyUserMessageCount), + [rows, stickyUserMessageCount], + ); + const hiddenStickyUserMessageIds = useHiddenStickyUserMessageIds({ + entries: stickyUserMessageEntries, + listRef, + timelineViewportRef, + enabled: stickyUserMessageCount > 0, + }); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); @@ -266,21 +290,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return ( - - ref={listRef} - data={rows} - keyExtractor={keyExtractor} - renderItem={renderItem} - estimatedItemSize={90} - initialScrollAtEnd - maintainScrollAtEnd - maintainScrollAtEndThreshold={0.1} - maintainVisibleContentPosition - onScroll={handleScroll} - className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" - ListHeaderComponent={TIMELINE_LIST_HEADER} - ListFooterComponent={TIMELINE_LIST_FOOTER} - /> +
+ + ref={listRef} + data={rows} + keyExtractor={keyExtractor} + renderItem={renderItem} + estimatedItemSize={90} + initialScrollAtEnd + maintainScrollAtEnd + maintainScrollAtEndThreshold={0.1} + maintainVisibleContentPosition + onScroll={handleScroll} + className="h-full overflow-x-hidden overscroll-y-contain px-3 sm:px-5" + ListHeaderComponent={TIMELINE_LIST_HEADER} + ListFooterComponent={TIMELINE_LIST_FOOTER} + /> + +
); @@ -290,15 +325,15 @@ function keyExtractor(item: MessagesTimelineRow) { return item.id; } -// --------------------------------------------------------------------------- -// TimelineRowContent — the actual row component -// --------------------------------------------------------------------------- - type TimelineEntry = ReturnType[number]; type TimelineMessage = Extract["message"]; type TimelineWorkEntry = Extract["groupedEntries"][number]; type TimelineRow = MessagesTimelineRow; +// --------------------------------------------------------------------------- +// TimelineRowContent — the actual row component +// --------------------------------------------------------------------------- + const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { return (
{row.kind === "work" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} diff --git a/apps/web/src/components/chat/StickyUserMessagesOverlay.tsx b/apps/web/src/components/chat/StickyUserMessagesOverlay.tsx new file mode 100644 index 00000000000..e0915b888c6 --- /dev/null +++ b/apps/web/src/components/chat/StickyUserMessagesOverlay.tsx @@ -0,0 +1,356 @@ +import { type MessageId, type ServerProviderSkill } from "@t3tools/contracts"; +import type { + StickyUserMessageCount, + StickyUserMessageMaxLines, + TimestampFormat, +} from "@t3tools/contracts/settings"; +import type { LegendListRef } from "@legendapp/list/react"; +import * as Equal from "effect/Equal"; +import { useEffect, useRef, useState, type RefObject } from "react"; + +import type { ChatMessage } from "../../types"; +import { formatTimestamp } from "../../timestampFormat"; +import { cn } from "~/lib/utils"; +import { deriveDisplayedUserMessageState } from "~/lib/terminalContext"; +import { SkillInlineText } from "./SkillInlineText"; +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; + +type UserTimelineMessage = ChatMessage & { role: "user" }; + +export interface StickyUserMessageEntry { + id: MessageId; + rowIndex: number; + message: UserTimelineMessage; +} + +export function deriveStickyUserMessageEntries( + rows: ReadonlyArray, + count: StickyUserMessageCount, +): StickyUserMessageEntry[] { + if (count <= 0) { + return []; + } + + const entries: StickyUserMessageEntry[] = []; + for (let rowIndex = rows.length - 1; rowIndex >= 0 && entries.length < count; rowIndex -= 1) { + const row = rows[rowIndex]; + if (row?.kind !== "message") { + continue; + } + + const { message } = row; + if (!isUserTimelineMessage(message)) { + continue; + } + + entries.unshift({ + id: message.id, + rowIndex, + message, + }); + } + + return entries; +} + +function isUserTimelineMessage(message: ChatMessage): message is UserTimelineMessage { + return message.role === "user"; +} + +export function useHiddenStickyUserMessageIds({ + entries, + listRef, + timelineViewportRef, + enabled, +}: { + entries: ReadonlyArray; + listRef: RefObject; + timelineViewportRef: RefObject; + enabled: boolean; +}): ReadonlySet { + const [hiddenMessageIds, setHiddenMessageIds] = useState>(() => new Set()); + const entriesRef = useRef(entries); + entriesRef.current = entries; + const entryKey = entries.map((entry) => entry.id).join("\n"); + + useEffect(() => { + if (!enabled || entryKey.length === 0) { + setHiddenMessageIds((current) => (current.size === 0 ? current : new Set())); + return; + } + + const timelineViewport = timelineViewportRef.current; + const scrollRoot = getTimelineScrollRoot(timelineViewport); + if (!timelineViewport || !scrollRoot) { + return; + } + + let disposed = false; + let animationFrameId: number | null = null; + + const measure = () => { + if (disposed) return; + const rootRect = scrollRoot.getBoundingClientRect(); + const listState = listRef.current?.getState?.(); + const currentEntries = entriesRef.current; + + setHiddenMessageIds((current) => { + const next = new Set(); + for (const entry of currentEntries) { + const source = findStickyUserMessageSource(timelineViewport, entry.id); + if (!source) { + if ( + (listState && entry.rowIndex < listState.start) || + (!listState && current.has(entry.id)) + ) { + next.add(entry.id); + } + continue; + } + + const sourceRect = source.getBoundingClientRect(); + const isVisible = sourceRect.bottom > rootRect.top && sourceRect.top < rootRect.bottom; + const isAboveViewport = sourceRect.bottom <= rootRect.top; + if (!isVisible && isAboveViewport) { + next.add(entry.id); + } + } + + return Equal.equals(current, next) ? current : next; + }); + }; + + const scheduleMeasure = () => { + if (animationFrameId !== null) { + return; + } + animationFrameId = window.requestAnimationFrame(() => { + animationFrameId = null; + measure(); + }); + }; + + measure(); + scrollRoot.addEventListener("scroll", scheduleMeasure, { passive: true }); + window.addEventListener("resize", scheduleMeasure); + + return () => { + disposed = true; + if (animationFrameId !== null) { + window.cancelAnimationFrame(animationFrameId); + } + scrollRoot.removeEventListener("scroll", scheduleMeasure); + window.removeEventListener("resize", scheduleMeasure); + }; + }, [enabled, entryKey, listRef, timelineViewportRef]); + + return hiddenMessageIds; +} + +export function StickyUserMessagesOverlay({ + entries, + hiddenMessageIds, + listRef, + maxLines, + skills, + timestampFormat, + timelineViewportRef, +}: { + entries: ReadonlyArray; + hiddenMessageIds: ReadonlySet; + listRef: RefObject; + maxLines: StickyUserMessageMaxLines; + skills: ReadonlyArray>; + timestampFormat: TimestampFormat; + timelineViewportRef: RefObject; +}) { + const visibleEntries = entries.filter((entry) => hiddenMessageIds.has(entry.id)); + + if (visibleEntries.length === 0) { + return null; + } + + return ( +
+
+ {visibleEntries.map((entry, index) => ( + + scrollToStickyUserMessageSource({ + entry, + listRef, + timelineViewportRef, + }) + } + /> + ))} +
+
+ ); +} + +function StickyUserMessageBubble({ + entry, + maxLines, + onClick, + showMeta, + skills, + timestampFormat, +}: { + entry: StickyUserMessageEntry; + maxLines: StickyUserMessageMaxLines; + onClick: () => void; + showMeta: boolean; + skills: ReadonlyArray>; + timestampFormat: TimestampFormat; +}) { + const [mounted, setMounted] = useState(false); + const displayedUserMessage = deriveDisplayedUserMessageState(entry.message.text); + const textRef = useRef(null); + const [hiddenLineCount, setHiddenLineCount] = useState(0); + + useEffect(() => { + const frameId = window.requestAnimationFrame(() => setMounted(true)); + return () => window.cancelAnimationFrame(frameId); + }, []); + + useEffect(() => { + if (!showMeta) { + setHiddenLineCount((current) => (current === 0 ? current : 0)); + return; + } + + let frameId: number | null = null; + const textElement = textRef.current; + + const measureHiddenLines = () => { + if (!textElement) { + setHiddenLineCount(0); + return; + } + + const lineHeight = Number.parseFloat(window.getComputedStyle(textElement).lineHeight); + if (!Number.isFinite(lineHeight) || lineHeight <= 0) { + setHiddenLineCount(0); + return; + } + + const totalLines = Math.ceil(textElement.scrollHeight / lineHeight); + const nextHiddenLineCount = Math.max(0, totalLines - maxLines); + setHiddenLineCount((current) => + current === nextHiddenLineCount ? current : nextHiddenLineCount, + ); + }; + + const scheduleMeasure = () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + frameId = window.requestAnimationFrame(() => { + frameId = null; + measureHiddenLines(); + }); + }; + + scheduleMeasure(); + window.addEventListener("resize", scheduleMeasure); + const observer = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasure); + if (textElement) { + observer?.observe(textElement); + } + + return () => { + if (frameId !== null) { + window.cancelAnimationFrame(frameId); + } + observer?.disconnect(); + window.removeEventListener("resize", scheduleMeasure); + }; + }, [displayedUserMessage.visibleText, maxLines, showMeta]); + + return ( + + ); +} + +function getTimelineScrollRoot(timelineViewport: HTMLDivElement | null): HTMLElement | null { + const firstChild = timelineViewport?.firstElementChild; + return firstChild instanceof HTMLElement ? firstChild : null; +} + +function findStickyUserMessageSource( + timelineViewport: HTMLDivElement, + messageId: MessageId, +): HTMLElement | null { + return timelineViewport.querySelector( + `[data-sticky-user-message-source='true'][data-message-id="${CSS.escape(messageId)}"]`, + ); +} + +function scrollToStickyUserMessageSource({ + entry, + listRef, + timelineViewportRef, +}: { + entry: StickyUserMessageEntry; + listRef: RefObject; + timelineViewportRef: RefObject; +}) { + const timelineViewport = timelineViewportRef.current; + const source = timelineViewport ? findStickyUserMessageSource(timelineViewport, entry.id) : null; + if (source) { + source.scrollIntoView({ block: "start", behavior: "smooth" }); + return; + } + + void listRef.current?.scrollToIndex({ + index: entry.rowIndex, + animated: true, + }); +} + +function formatHiddenLineCount(hiddenLineCount: number): string { + return hiddenLineCount === 1 ? "+1 line" : `+${hiddenLineCount} lines`; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e7f21da4809..eed832a4358 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -12,7 +12,11 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + DEFAULT_UNIFIED_SETTINGS, + type StickyUserMessageCount, + type StickyUserMessageMaxLines, +} from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; @@ -99,6 +103,18 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const STICKY_USER_MESSAGE_COUNT_OPTIONS = [ + { value: "0", setting: 0 as StickyUserMessageCount, label: "Off" }, + { value: "1", setting: 1 as StickyUserMessageCount, label: "1 message" }, + { value: "2", setting: 2 as StickyUserMessageCount, label: "2 messages" }, +] as const; + +const STICKY_USER_MESSAGE_LINE_OPTIONS = [ + { value: "1", setting: 1 as StickyUserMessageMaxLines, label: "1 line" }, + { value: "2", setting: 2 as StickyUserMessageMaxLines, label: "2 lines" }, + { value: "3", setting: 3 as StickyUserMessageMaxLines, label: "3 lines" }, +] as const; + const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); function withoutProviderInstanceKey( @@ -386,6 +402,9 @@ export function useSettingsRestore(onRestored?: () => void) { settings.textGenerationModelSelection ?? null, DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); + const isStickyUserMessagesDirty = + settings.stickyUserMessageCount !== DEFAULT_UNIFIED_SETTINGS.stickyUserMessageCount || + settings.stickyUserMessageMaxLines !== DEFAULT_UNIFIED_SETTINGS.stickyUserMessageMaxLines; const changedSettingLabels = useMemo( () => [ @@ -405,6 +424,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ["Auto-open task panel"] : []), + ...(isStickyUserMessagesDirty ? ["Sticky user messages"] : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -428,6 +448,7 @@ export function useSettingsRestore(onRestored?: () => void) { ], [ isGitWritingModelDirty, + isStickyUserMessagesDirty, settings.autoOpenPlanSidebar, settings.confirmThreadArchive, settings.confirmThreadDelete, @@ -459,6 +480,8 @@ export function useSettingsRestore(onRestored?: () => void) { diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, + stickyUserMessageCount: DEFAULT_UNIFIED_SETTINGS.stickyUserMessageCount, + stickyUserMessageMaxLines: DEFAULT_UNIFIED_SETTINGS.stickyUserMessageMaxLines, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, @@ -513,6 +536,9 @@ export function GeneralSettingsPanel() { settings.textGenerationModelSelection ?? null, DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); + const isStickyUserMessagesDirty = + settings.stickyUserMessageCount !== DEFAULT_UNIFIED_SETTINGS.stickyUserMessageCount || + settings.stickyUserMessageMaxLines !== DEFAULT_UNIFIED_SETTINGS.stickyUserMessageMaxLines; return ( @@ -669,6 +695,81 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + stickyUserMessageCount: DEFAULT_UNIFIED_SETTINGS.stickyUserMessageCount, + stickyUserMessageMaxLines: DEFAULT_UNIFIED_SETTINGS.stickyUserMessageMaxLines, + }) + } + /> + ) : null + } + control={ +
+ + +
+ } + /> + { sidebarProjectSortOrder: "manual" as const, sidebarThreadSortOrder: "created_at" as const, sidebarThreadPreviewCount: 6, + stickyUserMessageCount: 2 as const, + stickyUserMessageMaxLines: 3 as const, timestampFormat: "24-hour" as const, }; const getClientSettings = vi.fn().mockResolvedValue({ @@ -677,6 +679,8 @@ describe("wsApi", () => { sidebarProjectSortOrder: "manual" as const, sidebarThreadSortOrder: "created_at" as const, sidebarThreadPreviewCount: 6, + stickyUserMessageCount: 2 as const, + stickyUserMessageMaxLines: 3 as const, timestampFormat: "24-hour" as const, }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 39695fe3b01..f87c8a0a585 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -2,12 +2,47 @@ import { describe, expect, it } from "vitest"; import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; -import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; +import { + ClientSettingsPatch, + ClientSettingsSchema, + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + ServerSettings, + ServerSettingsPatch, +} from "./settings.ts"; +const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); +const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +describe("ClientSettings sticky user messages", () => { + it("defaults sticky user messages off with a two-line clamp", () => { + expect(DEFAULT_CLIENT_SETTINGS.stickyUserMessageCount).toBe(0); + expect(DEFAULT_CLIENT_SETTINGS.stickyUserMessageMaxLines).toBe(2); + + const decoded = decodeClientSettings({}); + expect(decoded.stickyUserMessageCount).toBe(0); + expect(decoded.stickyUserMessageMaxLines).toBe(2); + }); + + it("accepts bounded sticky user message patches", () => { + expect( + decodeClientSettingsPatch({ + stickyUserMessageCount: 2, + stickyUserMessageMaxLines: 3, + }), + ).toEqual({ + stickyUserMessageCount: 2, + stickyUserMessageMaxLines: 3, + }); + + expect(() => decodeClientSettingsPatch({ stickyUserMessageCount: 3 })).toThrow(); + expect(() => decodeClientSettingsPatch({ stickyUserMessageMaxLines: 4 })).toThrow(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..7c6e001a2e9 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -39,6 +39,28 @@ export const SidebarThreadPreviewCount = Schema.Int.check( export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +export const MIN_STICKY_USER_MESSAGE_COUNT = 0; +export const MAX_STICKY_USER_MESSAGE_COUNT = 2; +export const DEFAULT_STICKY_USER_MESSAGE_COUNT = 0; +export const StickyUserMessageCount = Schema.Int.check( + Schema.isBetween({ + minimum: MIN_STICKY_USER_MESSAGE_COUNT, + maximum: MAX_STICKY_USER_MESSAGE_COUNT, + }), +); +export type StickyUserMessageCount = typeof StickyUserMessageCount.Type; + +export const MIN_STICKY_USER_MESSAGE_LINES = 1; +export const MAX_STICKY_USER_MESSAGE_LINES = 3; +export const DEFAULT_STICKY_USER_MESSAGE_MAX_LINES = 2; +export const StickyUserMessageMaxLines = Schema.Int.check( + Schema.isBetween({ + minimum: MIN_STICKY_USER_MESSAGE_LINES, + maximum: MAX_STICKY_USER_MESSAGE_LINES, + }), +); +export type StickyUserMessageMaxLines = typeof StickyUserMessageMaxLines.Type; + export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -89,6 +111,12 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + stickyUserMessageCount: StickyUserMessageCount.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_STICKY_USER_MESSAGE_COUNT)), + ), + stickyUserMessageMaxLines: StickyUserMessageMaxLines.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_STICKY_USER_MESSAGE_MAX_LINES)), + ), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -508,6 +536,8 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + stickyUserMessageCount: Schema.optionalKey(StickyUserMessageCount), + stickyUserMessageMaxLines: Schema.optionalKey(StickyUserMessageMaxLines), timestampFormat: Schema.optionalKey(TimestampFormat), }); export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;