Skip to content
Merged
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
14 changes: 14 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2247,6 +2247,20 @@ describe("ChatView timeline estimator parity (full app)", () => {
},
{ timeout: 8_000, interval: 16 },
);

const provenance = await waitForElement(
() => document.querySelector<HTMLElement>('[data-fork-provenance="true"]'),
"Unable to find fork provenance after navigating to the forked conversation.",
);
expect(provenance.dataset.forkSourceThreadId).toBe(THREAD_ID);
expect(provenance.dataset.forkSourceMessageId).toBe(sourceMessageId);
expect(provenance.textContent).toContain(`Forked from a message in ${THREAD_TITLE}`);
const sourceLink = provenance.querySelector<HTMLButtonElement>(
`button[aria-label="Open source conversation: ${THREAD_TITLE}"]`,
);
expect(sourceLink).not.toBeNull();
sourceLink?.click();
await vi.waitFor(() => expect(mounted.router.state.location.pathname).toBe(`/${THREAD_ID}`));
} finally {
await mounted.cleanup();
}
Expand Down
21 changes: 19 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,11 @@ import {
ensureLeadingSpaceForReplacement,
extendReplacementRangeForTrailingSpace,
} from "../composerTriggerInsertion";
import { createProjectSelector, createThreadSelector } from "../storeSelectors";
import {
createProjectSelector,
createSidebarThreadSummarySelector,
createThreadSelector,
} from "../storeSelectors";
import {
canOfferForkSlashCommand,
canOfferSideSlashCommand,
Expand Down Expand Up @@ -450,6 +454,7 @@ import {
useAutomations,
} from "../routes/-automations.shared";
import { ChatTranscriptPane } from "./chat/ChatTranscriptPane";
import { resolveForkProvenance } from "./chat/forkProvenance";
import type { MessagesTimelineController } from "./chat/MessagesTimeline";
import { buildTurnDiffSummaryByAssistantMessageId } from "./chat/MessagesTimeline.logic";
import { deriveAgentActivityTimelineState } from "./chat/agentActivity.logic";
Expand Down Expand Up @@ -1737,6 +1742,14 @@ export default function ChatView({
[draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId],
);
const activeThread = serverThread ?? localDraftThread;
const forkSourceThreadId = activeThread?.forkSourceThreadId ?? null;
const forkSourceThread = useStore(
useMemo(() => createSidebarThreadSummarySelector(forkSourceThreadId), [forkSourceThreadId]),
);
const forkProvenance = useMemo(
() => (activeThread ? resolveForkProvenance(activeThread, forkSourceThread) : null),
[activeThread, forkSourceThread],
);
useEffect(() => {
if (
pendingFileUndo &&
Expand Down Expand Up @@ -3326,7 +3339,10 @@ export default function ChatView({
// Home-scoped chats get the global "What should we work on?" copy plus the project picker,
// while project-scoped drafts reuse the same centered layout with folder-specific copy.
const isCenteredEmptyLanding =
timelineEntries.length === 0 && !activeThread?.parentThreadId && !isEditorRail;
timelineEntries.length === 0 &&
forkProvenance === null &&
!activeThread?.parentThreadId &&
!isEditorRail;
const isEmptyChatLanding =
isCenteredEmptyLanding && Boolean(homeDir) && isContainerLandingProject;
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
Expand Down Expand Up @@ -11472,6 +11488,7 @@ export default function ChatView({
threadMarkers={threadMarkers}
enteringUserMessageIds={enteringUserMessageIds}
timelineEntries={timelineEntries}
forkProvenance={forkProvenance}
turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
onOpenTurnDiff={onOpenTurnDiff}
onOpenThread={onNavigateToThread}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/ChatTranscriptPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { MessageTrail } from "./MessageTrail";
import { createActiveTrailStore, deriveMessageTrailItems } from "./messageTrail.logic";
import { AgentActivityDetailView } from "./AgentActivityDetailView";
import type { AgentActivityDetail } from "./agentActivity.logic";
import type { ForkProvenance } from "./forkProvenance";

interface ChatTranscriptPaneProps {
activeThreadId: string;
Expand All @@ -49,6 +50,7 @@ interface ChatTranscriptPaneProps {
isRevertingCheckpoint: boolean;
isWorking: boolean;
followLiveOutput: boolean;
forkProvenance?: ForkProvenance | null;
listRef: RefObject<LegendListRef | null>;
timelineControllerRef?: RefObject<MessagesTimelineController | null>;
pinnedMessageIds?: ReadonlySet<MessageId>;
Expand Down Expand Up @@ -109,6 +111,7 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({
isRevertingCheckpoint,
isWorking,
followLiveOutput,
forkProvenance,
listRef,
timelineControllerRef,
pinnedMessageIds,
Expand Down Expand Up @@ -217,6 +220,7 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({
{...(threadMarkers ? { threadMarkers } : {})}
{...(enteringUserMessageIds ? { enteringUserMessageIds } : {})}
timelineEntries={timelineEntries}
{...(forkProvenance ? { forkProvenance } : {})}
turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
onOpenTurnDiff={onOpenTurnDiff}
onOpenThread={onOpenThread}
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/components/chat/ForkProvenanceMarker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// FILE: ForkProvenanceMarker.tsx
// Purpose: Render a compact, accessible source marker at the start of forked transcripts.
// Layer: Web chat presentation component

import type { ThreadId } from "@synara/contracts";

import { ConversationForkIcon } from "~/lib/icons";
import { cn } from "~/lib/utils";
import type { ForkProvenance } from "./forkProvenance";

interface ForkProvenanceMarkerProps {
provenance: ForkProvenance;
onOpenSource?: (threadId: ThreadId) => void;
}

export function ForkProvenanceMarker({ provenance, onOpenSource }: ForkProvenanceMarkerProps) {
const hasMessageBoundary = provenance.sourceMessageId !== null;
const sourceLabel = provenance.sourceTitle ?? "another conversation";
const canOpenSource = provenance.sourceAvailable && onOpenSource !== undefined;

return (
<div
role="note"
aria-label="Fork provenance"
data-fork-provenance="true"
data-fork-source-thread-id={provenance.sourceThreadId}
data-fork-source-message-id={provenance.sourceMessageId ?? undefined}
className="flex min-w-0 items-center gap-2 rounded-lg border border-[color:var(--color-border-light)] bg-[var(--color-background-elevated-primary)] px-3 py-2 font-system-ui text-xs text-muted-foreground"
>
<ConversationForkIcon
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground/70"
/>
<p className="min-w-0 [overflow-wrap:anywhere] leading-5">
<span>{hasMessageBoundary ? "Forked from a message in " : "Forked from "}</span>
{canOpenSource ? (
<button
type="button"
className={cn(
"max-w-full rounded-sm font-medium text-foreground/80 underline decoration-border underline-offset-2",
"hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
)}
aria-label={`Open source conversation: ${sourceLabel}`}
title={`Open source conversation: ${sourceLabel}`}
onClick={() => onOpenSource(provenance.sourceThreadId)}
>
{sourceLabel}
</button>
) : (
<span className="font-medium text-foreground/70">{sourceLabel}</span>
)}
{!provenance.sourceAvailable ? (
<span className="text-muted-foreground/70"> · Source unavailable</span>
) : null}
</p>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// FILE: MessagesTimeline.forkProvenance.browser.tsx
// Purpose: Browser regressions for accessible, responsive fork provenance in virtualized transcripts.
// Layer: Vitest browser tests

import "../../index.css";

import { MessageId, ThreadId } from "@synara/contracts";
import type { LegendListRef } from "@legendapp/list/react";
import { useRef, useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { page, userEvent } from "vitest/browser";
import { render } from "vitest-browser-react";

import type { deriveTimelineEntries } from "../../session-logic";
import { MessagesTimeline } from "./MessagesTimeline";
import type { ForkProvenance } from "./forkProvenance";

type TimelineEntries = ReturnType<typeof deriveTimelineEntries>;

const SOURCE_ID = ThreadId.makeUnsafe("fork-source-thread");

function entries(count: number): TimelineEntries {
return Array.from({ length: count }, (_, index) => {
const createdAt = new Date(Date.UTC(2026, 6, 26, 12, 0, index)).toISOString();
return {
id: `entry-${index}`,
kind: "message" as const,
createdAt,
message: {
id: MessageId.makeUnsafe(`message-${index}`),
role: "user" as const,
text: `Transcript message ${index}`,
createdAt,
streaming: false,
},
};
});
}

function ForkTimeline({
messageCount,
provenance,
width = 640,
}: {
messageCount: number;
provenance: ForkProvenance;
width?: number;
}) {
const listRef = useRef<LegendListRef | null>(null);
const [openedSource, setOpenedSource] = useState<string>("");
const timelineEntries = entries(messageCount);

return (
<div style={{ width }}>
<button
type="button"
data-testid="scroll-to-beginning"
onClick={() => void listRef.current?.scrollToIndex({ index: 0, animated: false })}
>
Go to beginning
</button>
<output data-testid="opened-source">{openedSource}</output>
<div style={{ height: 360 }}>
<MessagesTimeline
hasMessages={timelineEntries.length > 0}
isWorking={false}
activeTurnInProgress={false}
activeTurnStartedAt={null}
listRef={listRef}
timelineEntries={timelineEntries}
forkProvenance={provenance}
turnDiffSummaryByAssistantMessageId={new Map()}
expandedWorkGroups={{}}
onToggleWorkGroup={() => {}}
onOpenTurnDiff={() => {}}
onOpenThread={(threadId) => setOpenedSource(threadId)}
revertTurnCountByUserMessageId={new Map()}
onRevertUserMessage={() => {}}
isRevertingCheckpoint={false}
onImageExpand={() => {}}
markdownCwd={undefined}
resolvedTheme="dark"
timestampFormat="locale"
workspaceRoot={undefined}
/>
</div>
</div>
);
}

describe("MessagesTimeline fork provenance", () => {
afterEach(() => {
document.body.innerHTML = "";
});

it("identifies and opens the source of a message-boundary fork with keyboard access", async () => {
const screen = await render(
<ForkTimeline
width={300}
messageCount={1}
provenance={{
sourceThreadId: SOURCE_ID,
sourceMessageId: MessageId.makeUnsafe("source-message"),
sourceTitle: "Source-experiment-with-a-deliberately-unbroken-long-title",
sourceAvailable: true,
}}
/>,
);

try {
const note = page.getByRole("note", { name: "Fork provenance" });
await expect.element(note).toBeVisible();
await expect
.element(note)
.toHaveTextContent(
"Forked from a message in Source-experiment-with-a-deliberately-unbroken-long-title",
);

const sourceButtonElement = document.querySelector<HTMLButtonElement>(
'button[aria-label="Open source conversation: Source-experiment-with-a-deliberately-unbroken-long-title"]',
);
expect(sourceButtonElement).not.toBeNull();
sourceButtonElement?.focus();
expect(document.activeElement).toBe(sourceButtonElement);
await userEvent.keyboard("{Enter}");
await expect.element(page.getByTestId("opened-source")).toHaveTextContent(SOURCE_ID);
const scrollContainer = document.querySelector<HTMLElement>(
'[data-chat-scroll-container="true"]',
);
expect(scrollContainer).not.toBeNull();
expect(scrollContainer!.scrollWidth).toBeLessThanOrEqual(scrollContainer!.clientWidth);
} finally {
await screen.unmount();
}
});

it("renders a non-interactive unavailable-source fallback without horizontal overflow", async () => {
const screen = await render(
<ForkTimeline
width={300}
messageCount={0}
provenance={{
sourceThreadId: SOURCE_ID,
sourceMessageId: null,
sourceTitle: null,
sourceAvailable: false,
}}
/>,
);

try {
const note = page.getByRole("note", { name: "Fork provenance" });
await expect.element(note).toBeVisible();
await expect
.element(note)
.toHaveTextContent("Forked from another conversation · Source unavailable");
expect(document.querySelector('[data-fork-provenance="true"] button')).toBeNull();
const scrollContainer = document.querySelector<HTMLElement>(
'[data-chat-scroll-container="true"]',
);
expect(scrollContainer).not.toBeNull();
expect(scrollContainer!.scrollWidth).toBeLessThanOrEqual(scrollContainer!.clientWidth);
} finally {
await screen.unmount();
}
});

it("keeps provenance as the first virtualized row in a large restored transcript", async () => {
const screen = await render(
<ForkTimeline
messageCount={120}
provenance={{
sourceThreadId: SOURCE_ID,
sourceMessageId: null,
sourceTitle: "Restored source",
sourceAvailable: true,
}}
/>,
);

try {
await userEvent.click(page.getByTestId("scroll-to-beginning"));
await expect
.poll(() => document.querySelector('[data-timeline-row-kind="fork-provenance"]') !== null)
.toBe(true);
const provenanceRow = document.querySelector<HTMLElement>(
'[data-timeline-row-kind="fork-provenance"]',
);
const firstMessageRow = document.querySelector<HTMLElement>('[data-message-id="message-0"]');
expect(provenanceRow).not.toBeNull();
expect(firstMessageRow).not.toBeNull();
expect(provenanceRow!.getBoundingClientRect().top).toBeLessThan(
firstMessageRow!.getBoundingClientRect().top,
);
await expect
.element(page.getByRole("button", { name: "Open source conversation: Restored source" }))
.toBeVisible();
} finally {
await screen.unmount();
}
});
});
Loading
Loading