diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2b9eda1a787..d96f6204c3b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -139,6 +139,7 @@ import { usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; import { RightPanelTabs } from "./RightPanelTabs"; +import { MessageSurface } from "./chat/MessageSurface"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -2386,6 +2387,16 @@ function ChatViewContent(props: ChatViewProps) { deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); + const activeSurfaceMessage = useMemo(() => { + if (activeRightPanelSurface?.kind !== "message") return null; + const entry = timelineEntries.find( + (candidate) => + candidate.kind === "message" && + candidate.message.id === activeRightPanelSurface.messageId && + candidate.message.role !== "system", + ); + return entry?.kind === "message" ? entry.message : null; + }, [activeRightPanelSurface, timelineEntries]); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -5703,6 +5714,13 @@ function ChatViewContent(props: ChatViewProps) { timestampFormat={timestampFormat} mode="embedded" /> + ) : activeRightPanelSurface?.kind === "message" ? ( + ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && activeProject && activeWorkspaceRoot ? ( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index fe652b6fde7..a86488c7e96 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,15 @@ import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { + ClipboardList, + FileDiff, + Files, + Globe2, + MessageSquare, + Plus, + TerminalSquare, + X, +} from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, @@ -198,6 +207,8 @@ function surfaceTitle( return "Files"; case "file": return surface.relativePath.slice(surface.relativePath.lastIndexOf("/") + 1); + case "message": + return "Message"; case "terminal": return ( terminalLabelsById.get(surface.activeTerminalId) ?? @@ -264,6 +275,8 @@ function SurfaceIcon({ ); case "terminal": return ; + case "message": + return ; case "plan": return ; } diff --git a/apps/web/src/components/chat/MessageSurface.tsx b/apps/web/src/components/chat/MessageSurface.tsx new file mode 100644 index 00000000000..d6482463b19 --- /dev/null +++ b/apps/web/src/components/chat/MessageSurface.tsx @@ -0,0 +1,43 @@ +import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; + +import { deriveDisplayedUserMessageState } from "~/lib/terminalContext"; +import type { ChatMessage } from "~/types"; +import ChatMarkdown from "../ChatMarkdown"; + +export function MessageSurface({ + message, + threadRef, + cwd, + skills, +}: { + message: ChatMessage | null; + threadRef: ScopedThreadRef; + cwd: string | undefined; + skills: ReadonlyArray>; +}) { + if (!message) { + return ( +
+
+

Message unavailable

+

+ This message is no longer in the thread. +

+
+
+ ); + } + + const text = + message.role === "user" + ? deriveDisplayedUserMessageState(message.text).visibleText + : message.text; + + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a429b54deaf..653b4c6f8e7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -64,6 +64,7 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; import { MessageCopyButton } from "./MessageCopyButton"; +import { OpenMessageSurfaceButton } from "./OpenMessageSurfaceButton"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, @@ -97,6 +98,7 @@ import { } from "~/lib/previewAnnotation"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; @@ -133,6 +135,7 @@ interface TimelineRowSharedState { onRevertUserMessage: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onOpenMessageSurface: (messageId: MessageId) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; } @@ -223,6 +226,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); const [minimapStripMap] = useState(() => new Map()); + const threadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]); + const onOpenMessageSurface = useCallback( + (messageId: MessageId) => { + if (!threadRef) return; + useRightPanelStore.getState().openMessage(threadRef, messageId); + }, + [threadRef], + ); const onToggleTurnFold = useCallback((turnId: TurnId) => { setExpandedTurnIds((existing) => { @@ -419,7 +430,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ () => ({ timestampFormat, routeThreadKey, - threadRef: parseScopedThreadKey(routeThreadKey), + threadRef, markdownCwd, resolvedTheme, workspaceRoot, @@ -428,12 +439,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, onImageExpand, onOpenTurnDiff, + onOpenMessageSurface, onToggleTurnFold, onToggleWorkGroup, }), [ timestampFormat, routeThreadKey, + threadRef, markdownCwd, resolvedTheme, workspaceRoot, @@ -442,6 +455,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, onImageExpand, onOpenTurnDiff, + onOpenMessageSurface, onToggleTurnFold, onToggleWorkGroup, ], @@ -961,7 +975,7 @@ function UserTimelineRow({ row }: { row: Extract {canRevertAgentWork && } {displayedUserMessage.copyText && ( - + )} @@ -1037,7 +1051,7 @@ function AssistantTimelineRow({ row }: { row: Extract {row.showAssistantMeta ? (
- + {!row.message.streaming && ( }) { +function AssistantMessageActions({ row }: { row: Extract }) { const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, showCopyButton: row.showAssistantCopyButton, @@ -1068,7 +1082,18 @@ function AssistantCopyButton({ row }: { row: Extract; + return ; +} + +function MessageActions({ messageId, text }: { messageId: MessageId; text: string }) { + const ctx = use(TimelineRowCtx); + + return ( +
+ + ctx.onOpenMessageSurface(messageId)} /> +
+ ); } function ProposedPlanTimelineRow({ diff --git a/apps/web/src/components/chat/OpenMessageSurfaceButton.tsx b/apps/web/src/components/chat/OpenMessageSurfaceButton.tsx new file mode 100644 index 00000000000..f30d3dac36c --- /dev/null +++ b/apps/web/src/components/chat/OpenMessageSurfaceButton.tsx @@ -0,0 +1,26 @@ +import { PanelRightIcon } from "lucide-react"; + +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function OpenMessageSurfaceButton({ onClick }: { onClick: () => void }) { + return ( + + + } + > + + + Open as surface + + ); +} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index c7457cfd304..ce8695e1e43 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -1,5 +1,5 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { type EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; import { @@ -142,6 +142,24 @@ describe("rightPanelStore", () => { }); }); + it("stores message surfaces by id", () => { + const firstMessageId = MessageId.make("message-1"); + const secondMessageId = MessageId.make("message-2"); + + useRightPanelStore.getState().openMessage(refA, firstMessageId); + useRightPanelStore.getState().openMessage(refA, firstMessageId); + useRightPanelStore.getState().openMessage(refA, secondMessageId); + + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: "message:message-2", + surfaces: [ + { id: "message:message-1", kind: "message", messageId: firstMessageId }, + { id: "message:message-2", kind: "message", messageId: secondMessageId }, + ], + }); + }); + it("replaces the standalone explorer with peer file surfaces", () => { useRightPanelStore.getState().open(refA, "files"); useRightPanelStore.getState().openFile(refA, "src/index.ts"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 70d163306cc..78a030212fa 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -5,16 +5,25 @@ * surface descriptors and the active surface, while each feature continues to * own its durable resource state. Browser surfaces point at preview tab ids, * terminal surfaces point at terminal session ids, file surfaces point at - * workspace paths, and diff/plan/files remain singleton surfaces. + * workspace paths, message surfaces point at message ids, and diff/plan/files + * remain singleton surfaces. */ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { MessageId, ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; -export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const; +export const RIGHT_PANEL_KINDS = [ + "plan", + "diff", + "files", + "file", + "message", + "preview", + "terminal", +] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface = @@ -37,6 +46,11 @@ export type RightPanelSurface = revealLine: number | null; revealRequestId: number; } + | { + id: `message:${string}`; + kind: "message"; + messageId: MessageId; + } | { id: "plan"; kind: "plan" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; @@ -50,9 +64,13 @@ export interface ThreadRightPanelState { interface RightPanelStoreState { byThreadKey: Record; - open: (ref: ScopedThreadRef, kind: Exclude) => void; + open: ( + ref: ScopedThreadRef, + kind: Exclude, + ) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; + openMessage: (ref: ScopedThreadRef, messageId: MessageId) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( ref: ScopedThreadRef, @@ -72,7 +90,10 @@ interface RightPanelStoreState { show: (ref: ScopedThreadRef) => void; close: (ref: ScopedThreadRef) => void; toggleVisibility: (ref: ScopedThreadRef) => void; - toggle: (ref: ScopedThreadRef, kind: Exclude) => void; + toggle: ( + ref: ScopedThreadRef, + kind: Exclude, + ) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -83,7 +104,7 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = { }; const singletonSurface = ( - kind: Exclude, + kind: Exclude, ): RightPanelSurface => { switch (kind) { case "diff": @@ -120,6 +141,12 @@ const terminalSurface = (terminalId: string): RightPanelSurface => ({ activeTerminalId: terminalId, }); +const messageSurface = (messageId: MessageId): RightPanelSurface => ({ + id: `message:${messageId}`, + kind: "message", + messageId, +}); + const upsertSurface = ( current: ThreadRightPanelState, surface: RightPanelSurface, @@ -286,6 +313,12 @@ export const useRightPanelStore = create()( }; }), })), + openMessage: (ref, messageId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + upsertSurface(current, messageSurface(messageId)), + ), + })), openTerminal: (ref, terminalId) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>