{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;