Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7bfabe4
Refine assistant message copy button state and UX
shivamhwp Mar 19, 2026
8d776f7
Harden theme and terminal state storage for non-browser envs
shivamhwp Mar 19, 2026
741eb41
fix: resolve rebase fallout on assistant copy state
shivamhwp Apr 4, 2026
08dd481
fix: simplify message copy callbacks
shivamhwp Apr 4, 2026
92905a4
fix: stabilize assistant markdown height estimates
shivamhwp Apr 4, 2026
250be7a
Merge branch 'main' into feature/assistant-copy-state
shivamhwp Apr 5, 2026
fd955a3
Merge branch 'main' into feature/assistant-copy-state
shivamhwp Apr 5, 2026
f9a53ab
Simplify message copy button copy state
shivamhwp Apr 5, 2026
e8504de
Show copy only on terminal assistant messages
shivamhwp Apr 6, 2026
3da865a
Move message meta before copy controls
shivamhwp Apr 6, 2026
0d4beff
Remove redundant assistant copy disabled state
shivamhwp Apr 7, 2026
6c0a24d
Remove disabled copy button state
shivamhwp Apr 7, 2026
4994126
Merge branch 'main' into feature/assistant-copy-state
shivamhwp Apr 7, 2026
d19e592
Merge branch 'main' into feature/assistant-copy-state
shivamhwp Apr 8, 2026
1d34730
Merge upstream/main into feature/assistant-copy-state
shivamhwp Apr 10, 2026
a7a902c
Merge branch 'main' into feature/assistant-copy-state
shivamhwp Apr 10, 2026
ee61e27
Merge branch 'main' into feature/assistant-copy-state
shivamhwp Apr 11, 2026
a4e170b
Show disabled assistant copy button while streaming
shivamhwp Apr 11, 2026
31cbcd8
Hide assistant copy button until streaming completes
shivamhwp Apr 11, 2026
5945b3e
Hide assistant copy button until turn settles
shivamhwp Apr 11, 2026
afa41df
Show assistant copy button on row hover
juliusmarminge Apr 11, 2026
40aa530
Remove focus reveal from assistant copy controls
juliusmarminge Apr 11, 2026
5ee8129
anchored toast
juliusmarminge Apr 11, 2026
0b8b360
kewl
juliusmarminge Apr 11, 2026
90a1b7a
fuck tests
juliusmarminge Apr 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions apps/web/src/components/chat/MessageCopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,63 @@ import { memo } from "react";
import { CopyIcon, CheckIcon } from "lucide-react";
import { Button } from "../ui/button";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { cn } from "~/lib/utils";

export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) {
const { copyToClipboard, isCopied } = useCopyToClipboard();
type CopyCallbacks = {
onCopy?: () => void;
onError?: (error: Error) => void;
};

export const MessageCopyButton = memo(function MessageCopyButton({
text,
label,
title = "Copy message",
disabled = false,
disabledTitle,
size = "xs",
variant = "outline",
className,
onCopy,
onError,
}: {
text: string;
label?: string;
title?: string;
disabled?: boolean;
disabledTitle?: string;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
size?: "xs" | "icon-xs";
variant?: "outline" | "ghost";
className?: string;
onCopy?: () => void;
onError?: (error: Error) => void;
}) {
const { copyToClipboard, isCopied } = useCopyToClipboard<CopyCallbacks>({
onCopy: (callbacks) => {
callbacks.onCopy?.();
},
onError: (error, callbacks) => {
callbacks.onError?.(error);
},
});
Comment thread
cursor[bot] marked this conversation as resolved.
const buttonTitle = disabled ? (disabledTitle ?? title) : isCopied ? "Copied" : title;
const copyCallbacks = {
...(onCopy ? { onCopy } : {}),
...(onError ? { onError } : {}),
};
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

return (
<Button
type="button"
size="xs"
variant="outline"
onClick={() => copyToClipboard(text)}
title="Copy message"
size={size}
variant={variant}
className={cn(className)}
Comment thread
shivamhwp marked this conversation as resolved.
Outdated
disabled={disabled}
onClick={() => copyToClipboard(text, copyCallbacks)}
title={buttonTitle}
aria-label={buttonTitle}
>
{isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
{label ? <span>{label}</span> : null}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
</Button>
);
});
60 changes: 59 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
import {
computeMessageDurationStart,
normalizeCompactToolLabel,
resolveAssistantMessageCopyState,
} from "./MessagesTimeline.logic";

describe("computeMessageDurationStart", () => {
it("returns message createdAt when there is no preceding user message", () => {
Expand Down Expand Up @@ -143,3 +147,57 @@ describe("normalizeCompactToolLabel", () => {
expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file");
});
});

describe("resolveAssistantMessageCopyState", () => {
it("returns enabled copy state for completed assistant messages", () => {
expect(
resolveAssistantMessageCopyState({
text: "Ship it",
streaming: false,
}),
).toEqual({
disabled: false,
text: "Ship it",
visible: true,
});
});

it("keeps copy visible but disabled for streaming assistant messages", () => {
expect(
resolveAssistantMessageCopyState({
text: "Still streaming",
streaming: true,
}),
).toEqual({
disabled: true,
text: "Still streaming",
visible: true,
});
});

it("hides copy for empty completed assistant messages", () => {
expect(
resolveAssistantMessageCopyState({
text: " ",
streaming: false,
}),
).toEqual({
disabled: false,
text: null,
visible: false,
});
});

it("keeps copy visible while an empty assistant message is streaming", () => {
expect(
resolveAssistantMessageCopyState({
text: null,
streaming: true,
}),
).toEqual({
disabled: true,
text: null,
visible: true,
});
});
});
15 changes: 15 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ export function normalizeCompactToolLabel(value: string): string {
return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
}

export function resolveAssistantMessageCopyState({
text,
streaming,
}: {
text: string | null;
streaming: boolean;
}) {
const hasText = text !== null && text.trim().length > 0;
return {
disabled: streaming,
text: hasText ? text : null,
visible: streaming || hasText,
};
Comment thread
cursor[bot] marked this conversation as resolved.
}

export function deriveMessagesTimelineRows(input: {
timelineEntries: ReadonlyArray<TimelineEntry>;
completionDividerBeforeEntryId: string | null;
Expand Down
42 changes: 1 addition & 41 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,6 @@
import { MessageId } from "@t3tools/contracts";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeAll, describe, expect, it, vi } from "vitest";

function matchMedia() {
return {
matches: false,
addEventListener: () => {},
removeEventListener: () => {},
};
}

beforeAll(() => {
const classList = {
add: () => {},
remove: () => {},
toggle: () => {},
contains: () => false,
};

vi.stubGlobal("localStorage", {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
});
vi.stubGlobal("window", {
matchMedia,
addEventListener: () => {},
removeEventListener: () => {},
desktopBridge: undefined,
});
vi.stubGlobal("document", {
documentElement: {
classList,
offsetHeight: 0,
},
});
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 0;
});
});
import { describe, expect, it } from "vitest";

describe("MessagesTimeline", () => {
it("renders inline terminal labels with the composer chip UI", async () => {
Expand Down
52 changes: 43 additions & 9 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
deriveMessagesTimelineRows,
estimateMessagesTimelineRowHeight,
normalizeCompactToolLabel,
resolveAssistantMessageCopyState,
type MessagesTimelineRow,
} from "./MessagesTimeline.logic";
import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
Expand All @@ -55,6 +56,7 @@ import {
import { cn } from "~/lib/utils";
import { type TimestampFormat } from "@t3tools/contracts/settings";
import { formatTimestamp } from "../../timestampFormat";
import { toastManager } from "../ui/toast";
import {
buildInlineTerminalContextText,
formatInlineTerminalContextLabel,
Expand Down Expand Up @@ -438,6 +440,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
row.message.role === "assistant" &&
(() => {
const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
const assistantCopyState = resolveAssistantMessageCopyState({
text: row.message.text ?? null,
streaming: row.message.streaming,
});
return (
<>
{row.showCompletionDivider && (
Expand Down Expand Up @@ -512,15 +518,43 @@ export const MessagesTimeline = memo(function MessagesTimeline({
</div>
);
})()}
<p className="mt-1.5 text-[10px] text-muted-foreground/30">
{formatMessageMeta(
row.message.createdAt,
row.message.streaming
? formatElapsed(row.durationStart, nowIso)
: formatElapsed(row.durationStart, row.message.completedAt),
timestampFormat,
)}
</p>
<div className="mt-1.5 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
{assistantCopyState.visible ? (
<MessageCopyButton
text={assistantCopyState.text ?? ""}
title="Copy assistant response"
disabled={assistantCopyState.disabled}
disabledTitle="Copy available when response completes"
size="icon-xs"
variant="outline"
className="border-border/50 bg-background/35 text-muted-foreground/45 shadow-none hover:border-border/70 hover:bg-background/55 hover:text-muted-foreground/70"
onCopy={() => {
toastManager.add({
type: "success",
title: "Assistant response copied",
});
}}
onError={(error) => {
toastManager.add({
type: "error",
title: "Failed to copy assistant response",
description: error.message,
});
}}
Comment thread
shivamhwp marked this conversation as resolved.
Outdated
/>
) : null}
</div>
<p className="text-[10px] text-muted-foreground/30">
{formatMessageMeta(
row.message.createdAt,
row.message.streaming
? formatElapsed(row.durationStart, nowIso)
: formatElapsed(row.durationStart, row.message.completedAt),
timestampFormat,
)}
</p>
</div>
</div>
</>
);
Expand Down
31 changes: 26 additions & 5 deletions apps/web/src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,36 @@ type ThemeSnapshot = {

const STORAGE_KEY = "t3code:theme";
const MEDIA_QUERY = "(prefers-color-scheme: dark)";
const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = {
theme: "system",
systemDark: false,
};

let listeners: Array<() => void> = [];
let lastSnapshot: ThemeSnapshot | null = null;
let lastDesktopTheme: Theme | null = null;

function emitChange() {
for (const listener of listeners) listener();
}

function getSystemDark(): boolean {
return window.matchMedia(MEDIA_QUERY).matches;
function hasThemeStorage() {
return typeof window !== "undefined" && typeof localStorage !== "undefined";
}

function getSystemDark() {
return typeof window !== "undefined" && window.matchMedia(MEDIA_QUERY).matches;
}

function getStored(): Theme {
if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme;
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "light" || raw === "dark" || raw === "system") return raw;
return "system";
return DEFAULT_THEME_SNAPSHOT.theme;
}

function applyTheme(theme: Theme, suppressTransitions = false) {
if (typeof document === "undefined" || typeof window === "undefined") return;
if (suppressTransitions) {
document.documentElement.classList.add("no-transitions");
}
Expand All @@ -44,6 +55,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
}

function syncDesktopTheme(theme: Theme) {
if (typeof window === "undefined") return;
const bridge = window.desktopBridge;
if (!bridge || lastDesktopTheme === theme) {
return;
Expand All @@ -58,9 +70,12 @@ function syncDesktopTheme(theme: Theme) {
}

// Apply immediately on module load to prevent flash
applyTheme(getStored());
if (typeof document !== "undefined" && hasThemeStorage()) {
applyTheme(getStored());
}

function getSnapshot(): ThemeSnapshot {
if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT;
const theme = getStored();
const systemDark = theme === "system" ? getSystemDark() : false;

Expand All @@ -72,7 +87,12 @@ function getSnapshot(): ThemeSnapshot {
return lastSnapshot;
}

function getServerSnapshot() {
return DEFAULT_THEME_SNAPSHOT;
}

function subscribe(listener: () => void): () => void {
if (typeof window === "undefined") return () => {};
listeners.push(listener);

// Listen for system preference changes
Expand Down Expand Up @@ -100,13 +120,14 @@ function subscribe(listener: () => void): () => void {
}

export function useTheme() {
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const theme = snapshot.theme;

const resolvedTheme: "light" | "dark" =
theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme;

const setTheme = useCallback((next: Theme) => {
if (!hasThemeStorage()) return;
localStorage.setItem(STORAGE_KEY, next);
applyTheme(next, true);
emitChange();
Expand Down
Loading