Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | null>(null);
const draftHeroDockRequested =
activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;
Expand Down Expand Up @@ -5703,6 +5714,13 @@ function ChatViewContent(props: ChatViewProps) {
timestampFormat={timestampFormat}
mode="embedded"
/>
) : activeRightPanelSurface?.kind === "message" ? (
<MessageSurface
message={activeSurfaceMessage}
threadRef={activeThreadRef}
cwd={gitCwd ?? undefined}
skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
/>
) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") &&
activeProject &&
activeWorkspaceRoot ? (
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) ??
Expand Down Expand Up @@ -264,6 +275,8 @@ function SurfaceIcon({
);
case "terminal":
return <TerminalSquare className="size-3.5 shrink-0" />;
case "message":
return <MessageSquare className="size-3.5 shrink-0" />;
case "plan":
return <ClipboardList className="size-3.5 shrink-0" />;
}
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/components/chat/MessageSurface.tsx
Original file line number Diff line number Diff line change
@@ -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<Pick<ServerProviderSkill, "name" | "displayName">>;
}) {
if (!message) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center p-6" data-message-surface>
<div className="text-center">
<p className="text-sm font-medium text-foreground">Message unavailable</p>
<p className="mt-1 text-xs text-muted-foreground">
This message is no longer in the thread.
</p>
</div>
</div>
);
}

const text =
message.role === "user"
? deriveDisplayedUserMessageState(message.text).visibleText
: message.text;

return (
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
<div className="min-h-0 flex-1 overflow-y-auto" data-message-surface>
<div className="mx-auto w-full max-w-3xl px-5 py-5">
<ChatMarkdown text={text} cwd={cwd} threadRef={threadRef} skills={skills} />
Comment thread
cursor[bot] marked this conversation as resolved.
</div>
</div>
);
}
35 changes: 30 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -223,6 +226,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
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) => {
Expand Down Expand Up @@ -419,7 +430,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
() => ({
timestampFormat,
routeThreadKey,
threadRef: parseScopedThreadKey(routeThreadKey),
threadRef,
markdownCwd,
resolvedTheme,
workspaceRoot,
Expand All @@ -428,12 +439,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onRevertUserMessage,
onImageExpand,
onOpenTurnDiff,
onOpenMessageSurface,
onToggleTurnFold,
onToggleWorkGroup,
}),
[
timestampFormat,
routeThreadKey,
threadRef,
markdownCwd,
resolvedTheme,
workspaceRoot,
Expand All @@ -442,6 +455,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onRevertUserMessage,
onImageExpand,
onOpenTurnDiff,
onOpenMessageSurface,
onToggleTurnFold,
onToggleWorkGroup,
],
Expand Down Expand Up @@ -961,7 +975,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
<div className="flex items-center gap-0.5">
{canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />}
{displayedUserMessage.copyText && (
<MessageCopyButton text={displayedUserMessage.copyText} variant="ghost" />
<MessageActions messageId={row.message.id} text={displayedUserMessage.copyText} />
)}
</div>
</div>
Expand Down Expand Up @@ -1037,7 +1051,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
/>
{row.showAssistantMeta ? (
<div className="mt-1.5 flex items-center gap-2 text-xs tabular-nums opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover/assistant:opacity-100">
<AssistantCopyButton row={row} />
<AssistantMessageActions row={row} />
{!row.message.streaming && (
<Tooltip>
<TooltipTrigger
Expand All @@ -1057,7 +1071,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
);
}

function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
function AssistantMessageActions({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
const assistantCopyState = resolveAssistantMessageCopyState({
text: row.message.text ?? null,
showCopyButton: row.showAssistantCopyButton,
Expand All @@ -1068,7 +1082,18 @@ function AssistantCopyButton({ row }: { row: Extract<TimelineRow, { kind: "messa
return null;
}

return <MessageCopyButton text={assistantCopyState.text ?? ""} variant="ghost" />;
return <MessageActions messageId={row.message.id} text={assistantCopyState.text ?? ""} />;
}

function MessageActions({ messageId, text }: { messageId: MessageId; text: string }) {
const ctx = use(TimelineRowCtx);

return (
<div className="flex items-center gap-0.5">
<MessageCopyButton text={text} variant="ghost" />
<OpenMessageSurfaceButton onClick={() => ctx.onOpenMessageSurface(messageId)} />
</div>
);
}

function ProposedPlanTimelineRow({
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/components/chat/OpenMessageSurfaceButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="xs"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
aria-label="Open message as surface"
onClick={onClick}
/>
}
>
<PanelRightIcon className="size-3" />
</TooltipTrigger>
<TooltipPopup side="top">Open as surface</TooltipPopup>
</Tooltip>
);
}
20 changes: 19 additions & 1 deletion apps/web/src/rightPanelStore.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
45 changes: 39 additions & 6 deletions apps/web/src/rightPanelStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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";
Expand All @@ -50,9 +64,13 @@ export interface ThreadRightPanelState {

interface RightPanelStoreState {
byThreadKey: Record<string, ThreadRightPanelState>;
open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal">) => void;
open: (
ref: ScopedThreadRef,
kind: Exclude<RightPanelKind, "file" | "message" | "terminal">,
) => 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,
Expand All @@ -72,7 +90,10 @@ interface RightPanelStoreState {
show: (ref: ScopedThreadRef) => void;
close: (ref: ScopedThreadRef) => void;
toggleVisibility: (ref: ScopedThreadRef) => void;
toggle: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal">) => void;
toggle: (
ref: ScopedThreadRef,
kind: Exclude<RightPanelKind, "file" | "message" | "terminal">,
) => void;
removeThread: (ref: ScopedThreadRef) => void;
}

Expand All @@ -83,7 +104,7 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = {
};

const singletonSurface = (
kind: Exclude<RightPanelKind, "file" | "preview" | "terminal">,
kind: Exclude<RightPanelKind, "file" | "message" | "preview" | "terminal">,
): RightPanelSurface => {
switch (kind) {
case "diff":
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -286,6 +313,12 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
};
}),
})),
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) =>
Expand Down
Loading