mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add experimental newest-first issue thread (#5455)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies, so issue threads are a core operator surface for reviewing work. > - The issue detail page is the place where humans read agent messages, user comments, and execution context together. > - That thread originally rendered oldest-first, which made recent activity harder to see during active review. > - Reversing the thread order changes navigation expectations, timestamp placement, and the "Jump to latest" affordance, so the UI behavior needed to move as a coherent set. > - Because this is a visible core-product behavior shift, it also needed a safe rollout path instead of becoming the default immediately. > - This pull request adds the newest-first issue thread behavior behind an Experimental setting, updates the thread UI to match that mode, and keeps the legacy oldest-first experience unchanged by default. > - The benefit is that reviewers can opt into a more recent-first issue workflow without forcing a global behavior change on every Paperclip instance. ## What Changed - Reversed issue thread rendering so the newest comments and messages appear first when the experiment is enabled. - Moved the plain comment timestamp into the card header in newest-first mode and kept the legacy timestamp placement for oldest-first mode. - Moved the `Jump to latest` control to the bottom of the thread in newest-first mode while leaving the existing top placement for the legacy mode. - Added the `Enable Newest-First Issue Thread` experimental instance setting and wired issue detail to read that toggle. - Added regression coverage for thread order, timestamp placement, jump-button placement, and the issue-detail experiment toggle behavior. ## Verification - `pnpm -r typecheck` - `pnpm test:run` - `pnpm build` - Focused checks that also passed during issue review: - `pnpm vitest run src/components/IssueChatThread.test.tsx src/pages/IssueDetail.test.tsx` in `ui/` - `pnpm vitest run src/__tests__/instance-settings-routes.test.ts` in `server/` - Manual review path: - Enable `Instance Settings > Experimental > Enable Newest-First Issue Thread` - Open an issue with comments/messages and confirm newest activity renders first, timestamps move into the header, and `Jump to latest` sits below the thread - Disable the experiment and confirm the legacy oldest-first behavior returns ## Risks - Low risk: the behavioral change is gated behind an instance-level experimental toggle and defaults off. - The main regression risk is thread navigation drift between the two modes, especially around anchor scrolling and the `Jump to latest` affordance. - There is some UI coupling between issue-detail query state and experimental settings fetches, so future changes in that area should keep both modes covered. - Screenshots are not attached in this PR body; verification is described with automated coverage and manual steps instead. > I checked [`ROADMAP.md`](ROADMAP.md). This is a scoped issue-thread UX improvement and rollout gate, not a duplicate of a roadmap-level planned core feature. ## Model Used - OpenAI Codex via the local `codex_local` Paperclip adapter, GPT-5-based coding agent with terminal tool use and local code execution in this repository worktree. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
4269545b19
commit
a904effb96
9 changed files with 415 additions and 106 deletions
|
|
@ -29,6 +29,7 @@ export interface InstanceGeneralSettings {
|
||||||
export interface InstanceExperimentalSettings {
|
export interface InstanceExperimentalSettings {
|
||||||
enableEnvironments: boolean;
|
enableEnvironments: boolean;
|
||||||
enableIsolatedWorkspaces: boolean;
|
enableIsolatedWorkspaces: boolean;
|
||||||
|
enableNewestFirstIssueThread: boolean;
|
||||||
autoRestartDevServerWhenIdle: boolean;
|
autoRestartDevServerWhenIdle: boolean;
|
||||||
enableIssueGraphLivenessAutoRecovery: boolean;
|
enableIssueGraphLivenessAutoRecovery: boolean;
|
||||||
issueGraphLivenessAutoRecoveryLookbackHours: number;
|
issueGraphLivenessAutoRecoveryLookbackHours: number;
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.
|
||||||
export const instanceExperimentalSettingsSchema = z.object({
|
export const instanceExperimentalSettingsSchema = z.object({
|
||||||
enableEnvironments: z.boolean().default(false),
|
enableEnvironments: z.boolean().default(false),
|
||||||
enableIsolatedWorkspaces: z.boolean().default(false),
|
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||||
|
enableNewestFirstIssueThread: z.boolean().default(false),
|
||||||
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
||||||
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
|
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
|
||||||
issueGraphLivenessAutoRecoveryLookbackHours: z
|
issueGraphLivenessAutoRecoveryLookbackHours: z
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ describe("instance settings routes", () => {
|
||||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
||||||
enableEnvironments: false,
|
enableEnvironments: false,
|
||||||
enableIsolatedWorkspaces: false,
|
enableIsolatedWorkspaces: false,
|
||||||
|
enableNewestFirstIssueThread: false,
|
||||||
autoRestartDevServerWhenIdle: false,
|
autoRestartDevServerWhenIdle: false,
|
||||||
enableIssueGraphLivenessAutoRecovery: true,
|
enableIssueGraphLivenessAutoRecovery: true,
|
||||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||||
|
|
@ -81,6 +82,7 @@ describe("instance settings routes", () => {
|
||||||
experimental: {
|
experimental: {
|
||||||
enableEnvironments: true,
|
enableEnvironments: true,
|
||||||
enableIsolatedWorkspaces: true,
|
enableIsolatedWorkspaces: true,
|
||||||
|
enableNewestFirstIssueThread: false,
|
||||||
autoRestartDevServerWhenIdle: false,
|
autoRestartDevServerWhenIdle: false,
|
||||||
enableIssueGraphLivenessAutoRecovery: true,
|
enableIssueGraphLivenessAutoRecovery: true,
|
||||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||||
|
|
@ -123,6 +125,7 @@ describe("instance settings routes", () => {
|
||||||
expect(getRes.body).toEqual({
|
expect(getRes.body).toEqual({
|
||||||
enableEnvironments: false,
|
enableEnvironments: false,
|
||||||
enableIsolatedWorkspaces: false,
|
enableIsolatedWorkspaces: false,
|
||||||
|
enableNewestFirstIssueThread: false,
|
||||||
autoRestartDevServerWhenIdle: false,
|
autoRestartDevServerWhenIdle: false,
|
||||||
enableIssueGraphLivenessAutoRecovery: true,
|
enableIssueGraphLivenessAutoRecovery: true,
|
||||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
|
||||||
return {
|
return {
|
||||||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||||
|
enableNewestFirstIssueThread: parsed.data.enableNewestFirstIssueThread ?? false,
|
||||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||||
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
|
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
|
||||||
issueGraphLivenessAutoRecoveryLookbackHours:
|
issueGraphLivenessAutoRecoveryLookbackHours:
|
||||||
|
|
@ -51,6 +52,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
|
||||||
return {
|
return {
|
||||||
enableEnvironments: false,
|
enableEnvironments: false,
|
||||||
enableIsolatedWorkspaces: false,
|
enableIsolatedWorkspaces: false,
|
||||||
|
enableNewestFirstIssueThread: false,
|
||||||
autoRestartDevServerWhenIdle: false,
|
autoRestartDevServerWhenIdle: false,
|
||||||
enableIssueGraphLivenessAutoRecovery: false,
|
enableIssueGraphLivenessAutoRecovery: false,
|
||||||
issueGraphLivenessAutoRecoveryLookbackHours:
|
issueGraphLivenessAutoRecoveryLookbackHours:
|
||||||
|
|
|
||||||
|
|
@ -295,16 +295,19 @@ function createFileDragEvent(type: string, files: File[]) {
|
||||||
|
|
||||||
describe("IssueChatThread", () => {
|
describe("IssueChatThread", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
const originalDocumentElementScrollIntoView = document.documentElement.scrollIntoView;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
window.scrollTo = vi.fn();
|
window.scrollTo = vi.fn();
|
||||||
|
document.documentElement.scrollIntoView = vi.fn() as unknown as typeof document.documentElement.scrollIntoView;
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
container.remove();
|
container.remove();
|
||||||
|
document.documentElement.scrollIntoView = originalDocumentElementScrollIntoView;
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
appendMock.mockReset();
|
appendMock.mockReset();
|
||||||
markdownEditorFocusMock.mockReset();
|
markdownEditorFocusMock.mockReset();
|
||||||
|
|
@ -327,6 +330,7 @@ describe("IssueChatThread", () => {
|
||||||
liveRuns={[]}
|
liveRuns={[]}
|
||||||
onAdd={async () => {}}
|
onAdd={async () => {}}
|
||||||
showComposer={false}
|
showComposer={false}
|
||||||
|
newestFirst
|
||||||
enableLiveTranscriptPolling={false}
|
enableLiveTranscriptPolling={false}
|
||||||
/>
|
/>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
|
|
@ -336,6 +340,16 @@ describe("IssueChatThread", () => {
|
||||||
expect(container.textContent).toContain("Jump to latest");
|
expect(container.textContent).toContain("Jump to latest");
|
||||||
expect(container.textContent).not.toContain("Chat (");
|
expect(container.textContent).not.toContain("Chat (");
|
||||||
|
|
||||||
|
const threadRoot = container.querySelector('[data-testid="thread-root"]');
|
||||||
|
const jumpButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent === "Jump to latest",
|
||||||
|
);
|
||||||
|
expect(threadRoot).not.toBeNull();
|
||||||
|
expect(jumpButton).toBeDefined();
|
||||||
|
expect(
|
||||||
|
threadRoot?.compareDocumentPosition(jumpButton!),
|
||||||
|
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||||
|
|
||||||
const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null;
|
const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null;
|
||||||
expect(viewport).not.toBeNull();
|
expect(viewport).not.toBeNull();
|
||||||
expect(viewport?.className).not.toContain("overflow-y-auto");
|
expect(viewport?.className).not.toContain("overflow-y-auto");
|
||||||
|
|
@ -346,6 +360,106 @@ describe("IssueChatThread", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults to oldest-first rendering and jump placement", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[
|
||||||
|
{
|
||||||
|
id: "comment-older",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
authorAgentId: "agent-1",
|
||||||
|
authorUserId: null,
|
||||||
|
body: "Older comment",
|
||||||
|
authorType: "agent",
|
||||||
|
presentation: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "comment-newer",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
authorAgentId: "agent-1",
|
||||||
|
authorUserId: null,
|
||||||
|
body: "Newer comment",
|
||||||
|
authorType: "agent",
|
||||||
|
presentation: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
showComposer={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = Array.from(container.querySelectorAll('[data-testid="issue-chat-message-row"]'));
|
||||||
|
expect(rows[0]?.textContent).toContain("Older comment");
|
||||||
|
expect(rows[1]?.textContent).toContain("Newer comment");
|
||||||
|
|
||||||
|
const threadRoot = container.querySelector('[data-testid="thread-root"]');
|
||||||
|
const jumpButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent === "Jump to latest",
|
||||||
|
);
|
||||||
|
expect(threadRoot).not.toBeNull();
|
||||||
|
expect(jumpButton).toBeDefined();
|
||||||
|
expect(
|
||||||
|
threadRoot?.compareDocumentPosition(jumpButton!),
|
||||||
|
).toBe(Node.DOCUMENT_POSITION_PRECEDING);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the jump control above the thread when newest-first mode is disabled", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
showComposer={false}
|
||||||
|
newestFirst={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadRoot = container.querySelector('[data-testid="thread-root"]');
|
||||||
|
const jumpButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(button) => button.textContent === "Jump to latest",
|
||||||
|
);
|
||||||
|
expect(threadRoot).not.toBeNull();
|
||||||
|
expect(jumpButton).toBeDefined();
|
||||||
|
expect(
|
||||||
|
threadRoot?.compareDocumentPosition(jumpButton!),
|
||||||
|
).toBe(Node.DOCUMENT_POSITION_PRECEDING);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the composer in planning mode when the issue is in planning mode", () => {
|
it("renders the composer in planning mode when the issue is in planning mode", () => {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
|
@ -959,6 +1073,7 @@ describe("IssueChatThread", () => {
|
||||||
agentMap={issueChatLongThreadAgentMap}
|
agentMap={issueChatLongThreadAgentMap}
|
||||||
currentUserId="user-board"
|
currentUserId="user-board"
|
||||||
onAdd={async () => {}}
|
onAdd={async () => {}}
|
||||||
|
newestFirst
|
||||||
enableLiveTranscriptPolling={false}
|
enableLiveTranscriptPolling={false}
|
||||||
onRefreshLatestComments={async () => {
|
onRefreshLatestComments={async () => {
|
||||||
setComments([olderComment, latestComment]);
|
setComments([olderComment, latestComment]);
|
||||||
|
|
@ -995,7 +1110,7 @@ describe("IssueChatThread", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("findLatestCommentMessageIndex prefers the last comment-anchored row (PAP-2672)", () => {
|
it("findLatestCommentMessageIndex prefers the first comment-anchored row when newest renders first", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
{ metadata: { custom: { anchorId: "comment-a" } } },
|
{ metadata: { custom: { anchorId: "comment-a" } } },
|
||||||
{ metadata: { custom: { anchorId: "run-1" } } },
|
{ metadata: { custom: { anchorId: "run-1" } } },
|
||||||
|
|
@ -1003,7 +1118,7 @@ describe("IssueChatThread", () => {
|
||||||
{ metadata: { custom: { anchorId: "run-2" } } },
|
{ metadata: { custom: { anchorId: "run-2" } } },
|
||||||
{ metadata: { custom: { anchorId: "activity-3" } } },
|
{ metadata: { custom: { anchorId: "activity-3" } } },
|
||||||
];
|
];
|
||||||
expect(findLatestCommentMessageIndex(messages as never)).toBe(2);
|
expect(findLatestCommentMessageIndex(messages as never)).toBe(0);
|
||||||
expect(
|
expect(
|
||||||
findLatestCommentMessageIndex([
|
findLatestCommentMessageIndex([
|
||||||
{ metadata: { custom: { anchorId: "run-only" } } },
|
{ metadata: { custom: { anchorId: "run-only" } } },
|
||||||
|
|
@ -1012,6 +1127,17 @@ describe("IssueChatThread", () => {
|
||||||
expect(findLatestCommentMessageIndex([] as never)).toBe(-1);
|
expect(findLatestCommentMessageIndex([] as never)).toBe(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("findLatestCommentMessageIndex prefers the last comment-anchored row when newest-first mode is disabled", () => {
|
||||||
|
const messages = [
|
||||||
|
{ metadata: { custom: { anchorId: "comment-a" } } },
|
||||||
|
{ metadata: { custom: { anchorId: "run-1" } } },
|
||||||
|
{ metadata: { custom: { anchorId: "comment-b" } } },
|
||||||
|
{ metadata: { custom: { anchorId: "run-2" } } },
|
||||||
|
{ metadata: { custom: { anchorId: "activity-3" } } },
|
||||||
|
];
|
||||||
|
expect(findLatestCommentMessageIndex(messages as never, false)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the direct render path for short threads under the virtualization threshold", () => {
|
it("keeps the direct render path for short threads under the virtualization threshold", () => {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
const directComments = issueChatLongThreadComments.slice(0, 12);
|
const directComments = issueChatLongThreadComments.slice(0, 12);
|
||||||
|
|
@ -1720,6 +1846,50 @@ describe("IssueChatThread", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the comment timestamp above the comment body", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-04-08T12:00:00.000Z"));
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[{
|
||||||
|
id: "comment-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
authorAgentId: "agent-1",
|
||||||
|
authorUserId: null,
|
||||||
|
body: "Agent summary",
|
||||||
|
authorType: "agent",
|
||||||
|
presentation: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
}]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
showComposer={false}
|
||||||
|
newestFirst
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = container.textContent ?? "";
|
||||||
|
const timestampIndex = text.indexOf("2d ago");
|
||||||
|
expect(timestampIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(timestampIndex).toBeLessThan(text.indexOf("Agent summary"));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("shows deferred wake badge only for hold-deferred queued comments", () => {
|
it("shows deferred wake badge only for hold-deferred queued comments", () => {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
|
||||||
interface IssueChatMessageContext {
|
interface IssueChatMessageContext {
|
||||||
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
||||||
feedbackTermsUrl: string | null;
|
feedbackTermsUrl: string | null;
|
||||||
|
newestFirst: boolean;
|
||||||
agentMap?: Map<string, Agent>;
|
agentMap?: Map<string, Agent>;
|
||||||
currentUserId?: string | null;
|
currentUserId?: string | null;
|
||||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||||
|
|
@ -176,6 +177,7 @@ interface IssueChatMessageContext {
|
||||||
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||||
feedbackDataSharingPreference: "prompt",
|
feedbackDataSharingPreference: "prompt",
|
||||||
feedbackTermsUrl: null,
|
feedbackTermsUrl: null,
|
||||||
|
newestFirst: true,
|
||||||
issueStatus: undefined,
|
issueStatus: undefined,
|
||||||
successfulRunHandoff: null,
|
successfulRunHandoff: null,
|
||||||
});
|
});
|
||||||
|
|
@ -331,6 +333,7 @@ interface IssueChatThreadProps {
|
||||||
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
|
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
|
||||||
showComposer?: boolean;
|
showComposer?: boolean;
|
||||||
showJumpToLatest?: boolean;
|
showJumpToLatest?: boolean;
|
||||||
|
newestFirst?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
variant?: "full" | "embedded";
|
variant?: "full" | "embedded";
|
||||||
enableLiveTranscriptPolling?: boolean;
|
enableLiveTranscriptPolling?: boolean;
|
||||||
|
|
@ -530,7 +533,6 @@ function IssueChatFallbackThread({
|
||||||
|
|
||||||
const DRAFT_DEBOUNCE_MS = 800;
|
const DRAFT_DEBOUNCE_MS = 800;
|
||||||
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
|
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
|
||||||
const SUBMIT_SCROLL_RESERVE_VH = 0.4;
|
|
||||||
|
|
||||||
type ComposerAttachmentItem = {
|
type ComposerAttachmentItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -619,6 +621,33 @@ function commentDateLabel(date: Date | string | undefined): string {
|
||||||
return formatShortDate(date);
|
return formatShortDate(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IssueChatTimestampLink({
|
||||||
|
anchorId,
|
||||||
|
createdAt,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
anchorId?: string;
|
||||||
|
createdAt?: Date | string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (!createdAt) return null;
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<a
|
||||||
|
href={anchorId ? `#${anchorId}` : undefined}
|
||||||
|
className={cn("text-[11px] text-muted-foreground hover:text-foreground hover:underline", className)}
|
||||||
|
>
|
||||||
|
{commentDateLabel(createdAt)}
|
||||||
|
</a>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs">
|
||||||
|
{formatDateTime(createdAt)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
||||||
const { onImageClick } = useContext(IssueChatCtx);
|
const { onImageClick } = useContext(IssueChatCtx);
|
||||||
if (isSuccessfulRunHandoffComment(text)) {
|
if (isSuccessfulRunHandoffComment(text)) {
|
||||||
|
|
@ -1259,6 +1288,7 @@ function IssueChatUserMessage({
|
||||||
onCancelQueued,
|
onCancelQueued,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
userProfileMap,
|
userProfileMap,
|
||||||
|
newestFirst,
|
||||||
} = useContext(IssueChatCtx);
|
} = useContext(IssueChatCtx);
|
||||||
const custom = message.metadata.custom as Record<string, unknown>;
|
const custom = message.metadata.custom as Record<string, unknown>;
|
||||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||||
|
|
@ -1290,13 +1320,20 @@ function IssueChatUserMessage({
|
||||||
);
|
);
|
||||||
const messageBody = (
|
const messageBody = (
|
||||||
<div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}>
|
<div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}>
|
||||||
<div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
|
<div className={cn("mb-1 flex w-full items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
|
||||||
<span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span>
|
<span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span>
|
||||||
{followUpRequested ? (
|
{followUpRequested ? (
|
||||||
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
|
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
|
||||||
Follow-up
|
Follow-up
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{newestFirst ? (
|
||||||
|
<IssueChatTimestampLink
|
||||||
|
anchorId={anchorId}
|
||||||
|
createdAt={message.createdAt}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -1351,19 +1388,12 @@ function IssueChatUserMessage({
|
||||||
isCurrentUser ? "justify-end" : "justify-start",
|
isCurrentUser ? "justify-end" : "justify-start",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
{!newestFirst ? (
|
||||||
<TooltipTrigger asChild>
|
<IssueChatTimestampLink
|
||||||
<a
|
anchorId={anchorId}
|
||||||
href={anchorId ? `#${anchorId}` : undefined}
|
createdAt={message.createdAt}
|
||||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
/>
|
||||||
>
|
) : null}
|
||||||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className="text-xs">
|
|
||||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
|
@ -1423,6 +1453,7 @@ function IssueChatAssistantMessage({
|
||||||
onVote,
|
onVote,
|
||||||
agentMap,
|
agentMap,
|
||||||
onStopRun,
|
onStopRun,
|
||||||
|
newestFirst,
|
||||||
stopRunLabel = "Stop run",
|
stopRunLabel = "Stop run",
|
||||||
stoppingRunLabel = "Stopping...",
|
stoppingRunLabel = "Stopping...",
|
||||||
stopRunVariant = "stop",
|
stopRunVariant = "stop",
|
||||||
|
|
@ -1513,18 +1544,27 @@ function IssueChatAssistantMessage({
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="mb-1.5 flex items-center gap-2">
|
<div className="mb-1.5 flex items-center justify-between gap-3">
|
||||||
<span className="text-sm font-medium text-foreground">{authorName}</span>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{followUpRequested ? (
|
<span className="text-sm font-medium text-foreground">{authorName}</span>
|
||||||
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
|
{followUpRequested ? (
|
||||||
Follow-up
|
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
|
||||||
</Badge>
|
Follow-up
|
||||||
) : null}
|
</Badge>
|
||||||
{isRunning ? (
|
) : null}
|
||||||
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-400/40 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-700 dark:text-cyan-200">
|
{isRunning ? (
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-400/40 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-700 dark:text-cyan-200">
|
||||||
Running
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
</span>
|
Running
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{newestFirst ? (
|
||||||
|
<IssueChatTimestampLink
|
||||||
|
anchorId={anchorId}
|
||||||
|
createdAt={message.createdAt}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1582,19 +1622,12 @@ function IssueChatAssistantMessage({
|
||||||
onVote={handleVote}
|
onVote={handleVote}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Tooltip>
|
{!newestFirst ? (
|
||||||
<TooltipTrigger asChild>
|
<IssueChatTimestampLink
|
||||||
<a
|
anchorId={anchorId}
|
||||||
href={anchorId ? `#${anchorId}` : undefined}
|
createdAt={message.createdAt}
|
||||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
/>
|
||||||
>
|
) : null}
|
||||||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className="text-xs">
|
|
||||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -2639,7 +2672,32 @@ function findMessageAnchorIndex(messages: readonly ThreadMessage[], anchorId: st
|
||||||
return messages.findIndex((message) => issueChatMessageAnchorId(message) === anchorId);
|
return messages.findIndex((message) => issueChatMessageAnchorId(message) === anchorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findLatestCommentMessageIndex(messages: readonly ThreadMessage[]): number {
|
function findLatestMessageByRole(
|
||||||
|
messages: readonly ThreadMessage[],
|
||||||
|
role: ThreadMessage["role"],
|
||||||
|
newestFirst: boolean,
|
||||||
|
): ThreadMessage | undefined {
|
||||||
|
if (newestFirst) {
|
||||||
|
return messages.find((message) => message.role === role);
|
||||||
|
}
|
||||||
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (messages[index]?.role === role) return messages[index];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLatestCommentMessageIndex(
|
||||||
|
messages: readonly ThreadMessage[],
|
||||||
|
newestFirst = true,
|
||||||
|
): number {
|
||||||
|
if (newestFirst) {
|
||||||
|
for (let index = 0; index < messages.length; index += 1) {
|
||||||
|
const anchorId = issueChatMessageAnchorId(messages[index]);
|
||||||
|
if (anchorId && anchorId.startsWith("comment-")) return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
const anchorId = issueChatMessageAnchorId(messages[index]);
|
const anchorId = issueChatMessageAnchorId(messages[index]);
|
||||||
if (anchorId && anchorId.startsWith("comment-")) return index;
|
if (anchorId && anchorId.startsWith("comment-")) return index;
|
||||||
|
|
@ -3635,6 +3693,7 @@ export function IssueChatThread({
|
||||||
composerHint = null,
|
composerHint = null,
|
||||||
showComposer = true,
|
showComposer = true,
|
||||||
showJumpToLatest,
|
showJumpToLatest,
|
||||||
|
newestFirst = false,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
variant = "full",
|
variant = "full",
|
||||||
enableLiveTranscriptPolling = true,
|
enableLiveTranscriptPolling = true,
|
||||||
|
|
@ -3661,17 +3720,14 @@ export function IssueChatThread({
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const lastScrolledHashRef = useRef<string | null>(null);
|
const lastScrolledHashRef = useRef<string | null>(null);
|
||||||
const virtualizedThreadRef = useRef<VirtualizedIssueChatThreadListHandle | null>(null);
|
const virtualizedThreadRef = useRef<VirtualizedIssueChatThreadListHandle | null>(null);
|
||||||
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
|
const topAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null);
|
const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const composerViewportSnapshotRef = useRef<ReturnType<typeof captureComposerViewportSnapshot>>(null);
|
const composerViewportSnapshotRef = useRef<ReturnType<typeof captureComposerViewportSnapshot>>(null);
|
||||||
const preserveComposerViewportRef = useRef(false);
|
const preserveComposerViewportRef = useRef(false);
|
||||||
const pendingSubmitScrollRef = useRef(false);
|
const pendingSubmitScrollRef = useRef(false);
|
||||||
const lastUserMessageIdRef = useRef<string | null>(null);
|
const lastUserMessageIdRef = useRef<string | null>(null);
|
||||||
const spacerBaselineAnchorRef = useRef<string | null>(null);
|
|
||||||
const spacerInitialReserveRef = useRef(0);
|
|
||||||
const latestSettleTimeoutsRef = useRef<number[]>([]);
|
const latestSettleTimeoutsRef = useRef<number[]>([]);
|
||||||
const latestSettleCleanupRef = useRef<(() => void) | null>(null);
|
const latestSettleCleanupRef = useRef<(() => void) | null>(null);
|
||||||
const [bottomSpacerHeight, setBottomSpacerHeight] = useState(0);
|
|
||||||
const displayLiveRuns = useMemo(() => {
|
const displayLiveRuns = useMemo(() => {
|
||||||
const deduped = new Map<string, LiveRunForIssue>();
|
const deduped = new Map<string, LiveRunForIssue>();
|
||||||
for (const run of liveRuns) {
|
for (const run of liveRuns) {
|
||||||
|
|
@ -3765,7 +3821,7 @@ export function IssueChatThread({
|
||||||
);
|
);
|
||||||
const stableMessagesRef = useRef<readonly ThreadMessage[]>([]);
|
const stableMessagesRef = useRef<readonly ThreadMessage[]>([]);
|
||||||
const stableMessageCacheRef = useRef<Map<string, StableThreadMessageCacheEntry>>(new Map());
|
const stableMessageCacheRef = useRef<Map<string, StableThreadMessageCacheEntry>>(new Map());
|
||||||
const messages = useMemo(() => {
|
const ascendingMessages = useMemo(() => {
|
||||||
const stabilized = stabilizeThreadMessages(
|
const stabilized = stabilizeThreadMessages(
|
||||||
rawMessages,
|
rawMessages,
|
||||||
stableMessagesRef.current,
|
stableMessagesRef.current,
|
||||||
|
|
@ -3775,6 +3831,10 @@ export function IssueChatThread({
|
||||||
stableMessageCacheRef.current = stabilized.cache;
|
stableMessageCacheRef.current = stabilized.cache;
|
||||||
return stabilized.messages;
|
return stabilized.messages;
|
||||||
}, [rawMessages]);
|
}, [rawMessages]);
|
||||||
|
const messages = useMemo(
|
||||||
|
() => newestFirst ? [...ascendingMessages].reverse() : ascendingMessages,
|
||||||
|
[ascendingMessages, newestFirst],
|
||||||
|
);
|
||||||
const latestMessagesRef = useRef<readonly ThreadMessage[]>(messages);
|
const latestMessagesRef = useRef<readonly ThreadMessage[]>(messages);
|
||||||
latestMessagesRef.current = messages;
|
latestMessagesRef.current = messages;
|
||||||
|
|
||||||
|
|
@ -3849,48 +3909,26 @@ export function IssueChatThread({
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
|
const latestUserMessage = findLatestMessageByRole(messages, "user", newestFirst);
|
||||||
const lastUserId = lastUserMessage?.id ?? null;
|
const latestUserId = latestUserMessage?.id ?? null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
pendingSubmitScrollRef.current
|
pendingSubmitScrollRef.current
|
||||||
&& lastUserId
|
&& latestUserId
|
||||||
&& lastUserId !== lastUserMessageIdRef.current
|
&& latestUserId !== lastUserMessageIdRef.current
|
||||||
) {
|
) {
|
||||||
pendingSubmitScrollRef.current = false;
|
pendingSubmitScrollRef.current = false;
|
||||||
const custom = lastUserMessage?.metadata.custom as { anchorId?: unknown } | undefined;
|
const custom = latestUserMessage?.metadata.custom as { anchorId?: unknown } | undefined;
|
||||||
const anchorId = typeof custom?.anchorId === "string" ? custom.anchorId : null;
|
const anchorId = typeof custom?.anchorId === "string" ? custom.anchorId : null;
|
||||||
if (anchorId) {
|
if (anchorId) {
|
||||||
const reserve = Math.round(window.innerHeight * SUBMIT_SCROLL_RESERVE_VH);
|
|
||||||
spacerBaselineAnchorRef.current = anchorId;
|
|
||||||
spacerInitialReserveRef.current = reserve;
|
|
||||||
setBottomSpacerHeight(reserve);
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scrollToThreadAnchor(anchorId, { align: "start", behavior: "smooth" });
|
scrollToThreadAnchor(anchorId, { align: "start", behavior: "smooth" });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastUserMessageIdRef.current = lastUserId;
|
lastUserMessageIdRef.current = latestUserId;
|
||||||
}, [messageAnchorIndex, messages, useVirtualizedThread]);
|
}, [messageAnchorIndex, messages, newestFirst, useVirtualizedThread]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const anchorId = spacerBaselineAnchorRef.current;
|
|
||||||
if (!anchorId || spacerInitialReserveRef.current <= 0) return;
|
|
||||||
const userEl = document.getElementById(anchorId);
|
|
||||||
const bottomEl = bottomAnchorRef.current;
|
|
||||||
if (!userEl || !bottomEl) return;
|
|
||||||
const contentBelow = Math.max(
|
|
||||||
0,
|
|
||||||
bottomEl.getBoundingClientRect().top - userEl.getBoundingClientRect().bottom,
|
|
||||||
);
|
|
||||||
const next = Math.max(0, spacerInitialReserveRef.current - contentBelow);
|
|
||||||
setBottomSpacerHeight((prev) => (prev === next ? prev : next));
|
|
||||||
if (next === 0) {
|
|
||||||
spacerBaselineAnchorRef.current = null;
|
|
||||||
spacerInitialReserveRef.current = 0;
|
|
||||||
}
|
|
||||||
}, [messages]);
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const composerElement = composerViewportAnchorRef.current;
|
const composerElement = composerViewportAnchorRef.current;
|
||||||
if (preserveComposerViewportRef.current) {
|
if (preserveComposerViewportRef.current) {
|
||||||
|
|
@ -3938,26 +3976,33 @@ export function IssueChatThread({
|
||||||
|
|
||||||
function jumpToLatestFallback() {
|
function jumpToLatestFallback() {
|
||||||
if (useVirtualizedThread) {
|
if (useVirtualizedThread) {
|
||||||
virtualizedThreadRef.current?.scrollToLatest({ behavior: "smooth" });
|
if (newestFirst) {
|
||||||
|
virtualizedThreadRef.current?.scrollToIndex(0, { align: "start", behavior: "smooth" });
|
||||||
|
} else {
|
||||||
|
virtualizedThreadRef.current?.scrollToLatest({ behavior: "smooth" });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bottomAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
if (newestFirst) {
|
||||||
|
topAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.documentElement.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lands on the latest `comment-*` row and then drives the scroll the rest
|
// Lands on the latest `comment-*` row and then keeps nudging the scroll
|
||||||
// of the way home as the virtualizer's per-row measurements arrive.
|
// until that newest comment is pinned to the top edge as row measurements
|
||||||
|
// settle in the virtualizer.
|
||||||
//
|
//
|
||||||
// The virtualizer estimates 220px for unmeasured rows. On long threads
|
// The virtualizer estimates 220px for unmeasured rows. On long threads
|
||||||
// with tall markdown comments (PAP-2536 et al.), totalSize is hugely
|
// with tall markdown comments (PAP-2536 et al.), totalSize is hugely
|
||||||
// underestimated until rows render and get measured. A single scroll
|
// underestimated until rows render and get measured. A single scroll can
|
||||||
// lands above the actual bottom; rendered rows then expand, the layout
|
// land below the true newest row; rendered rows then expand and shift. The
|
||||||
// grows, and the user has to keep clicking Jump-to-latest to walk closer
|
// convergence loop below keeps issuing `scrollIntoView` until the newest
|
||||||
// to the real bottom. The convergence loop below issues `scrollIntoView`
|
// comment element is at the scroll container's top edge (or the layout
|
||||||
// on the latest comment element on every tick until the DOM bottom of
|
// stops changing).
|
||||||
// that element is at the scroll container's bottom (or scroll position
|
|
||||||
// and content height stop changing).
|
|
||||||
function scrollToLatestCommentWithSettle(messageSnapshot: readonly ThreadMessage[] = latestMessagesRef.current) {
|
function scrollToLatestCommentWithSettle(messageSnapshot: readonly ThreadMessage[] = latestMessagesRef.current) {
|
||||||
const latestCommentIndex = findLatestCommentMessageIndex(messageSnapshot);
|
const latestCommentIndex = findLatestCommentMessageIndex(messageSnapshot, newestFirst);
|
||||||
if (latestCommentIndex < 0) {
|
if (latestCommentIndex < 0) {
|
||||||
jumpToLatestFallback();
|
jumpToLatestFallback();
|
||||||
return;
|
return;
|
||||||
|
|
@ -3970,7 +4015,7 @@ export function IssueChatThread({
|
||||||
|
|
||||||
const initial = scrollToThreadAnchor(
|
const initial = scrollToThreadAnchor(
|
||||||
latestCommentAnchor,
|
latestCommentAnchor,
|
||||||
{ align: "end", behavior: "smooth" },
|
{ align: "start", behavior: "smooth" },
|
||||||
messageSnapshot,
|
messageSnapshot,
|
||||||
);
|
);
|
||||||
if (!initial) {
|
if (!initial) {
|
||||||
|
|
@ -4042,7 +4087,7 @@ export function IssueChatThread({
|
||||||
// Row hasn't been rendered into the virtualizer's buffer yet — nudge
|
// Row hasn't been rendered into the virtualizer's buffer yet — nudge
|
||||||
// the offset (instant) so it gets mounted, then keep settling.
|
// the offset (instant) so it gets mounted, then keep settling.
|
||||||
virtualizedThreadRef.current?.scrollToIndex(latestCommentIndex, {
|
virtualizedThreadRef.current?.scrollToIndex(latestCommentIndex, {
|
||||||
align: "end",
|
align: "start",
|
||||||
behavior: "auto",
|
behavior: "auto",
|
||||||
});
|
});
|
||||||
scheduleTick(TICK_MS);
|
scheduleTick(TICK_MS);
|
||||||
|
|
@ -4050,22 +4095,22 @@ export function IssueChatThread({
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = resolveScrollContainer();
|
const container = resolveScrollContainer();
|
||||||
const containerBottom = container
|
const containerTop = container
|
||||||
? container.getBoundingClientRect().bottom
|
? container.getBoundingClientRect().top
|
||||||
: window.innerHeight;
|
: 0;
|
||||||
const elBottom = el.getBoundingClientRect().bottom;
|
const elTop = el.getBoundingClientRect().top;
|
||||||
const offBottom = elBottom - containerBottom;
|
const offTop = elTop - containerTop;
|
||||||
|
|
||||||
if (Math.abs(offBottom) > TOLERANCE_PX) {
|
if (Math.abs(offTop) > TOLERANCE_PX) {
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "end" });
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentScrollTop = container?.scrollTop ?? window.scrollY;
|
const currentScrollTop = container?.scrollTop ?? window.scrollY;
|
||||||
const currentScrollHeight = container?.scrollHeight ?? document.documentElement.scrollHeight;
|
const currentScrollHeight = container?.scrollHeight ?? document.documentElement.scrollHeight;
|
||||||
const scrollStable = Math.abs(currentScrollTop - lastScrollTop) < 1;
|
const scrollStable = Math.abs(currentScrollTop - lastScrollTop) < 1;
|
||||||
const heightStable = currentScrollHeight === lastScrollHeight;
|
const heightStable = currentScrollHeight === lastScrollHeight;
|
||||||
const atBottom = Math.abs(offBottom) <= TOLERANCE_PX;
|
const atTop = Math.abs(offTop) <= TOLERANCE_PX;
|
||||||
if (scrollStable && heightStable && atBottom) {
|
if (scrollStable && heightStable && atTop) {
|
||||||
stableTicks += 1;
|
stableTicks += 1;
|
||||||
if (stableTicks >= 3) {
|
if (stableTicks >= 3) {
|
||||||
finish();
|
finish();
|
||||||
|
|
@ -4118,6 +4163,7 @@ export function IssueChatThread({
|
||||||
() => ({
|
() => ({
|
||||||
feedbackDataSharingPreference,
|
feedbackDataSharingPreference,
|
||||||
feedbackTermsUrl,
|
feedbackTermsUrl,
|
||||||
|
newestFirst,
|
||||||
agentMap,
|
agentMap,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
userLabelMap,
|
userLabelMap,
|
||||||
|
|
@ -4140,6 +4186,7 @@ export function IssueChatThread({
|
||||||
[
|
[
|
||||||
feedbackDataSharingPreference,
|
feedbackDataSharingPreference,
|
||||||
feedbackTermsUrl,
|
feedbackTermsUrl,
|
||||||
|
newestFirst,
|
||||||
agentMap,
|
agentMap,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
userLabelMap,
|
userLabelMap,
|
||||||
|
|
@ -4178,7 +4225,7 @@ export function IssueChatThread({
|
||||||
<AssistantRuntimeProvider runtime={runtime}>
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
<IssueChatCtx.Provider value={chatCtx}>
|
<IssueChatCtx.Provider value={chatCtx}>
|
||||||
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
|
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
|
||||||
{resolvedShowJumpToLatest ? (
|
{resolvedShowJumpToLatest && !newestFirst ? (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -4189,7 +4236,6 @@ export function IssueChatThread({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<IssueChatErrorBoundary
|
<IssueChatErrorBoundary
|
||||||
resetKey={errorBoundaryResetKey}
|
resetKey={errorBoundaryResetKey}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
|
@ -4201,6 +4247,7 @@ export function IssueChatThread({
|
||||||
data-testid="thread-viewport"
|
data-testid="thread-viewport"
|
||||||
className={variant === "embedded" ? "space-y-3" : "space-y-4"}
|
className={variant === "embedded" ? "space-y-3" : "space-y-4"}
|
||||||
>
|
>
|
||||||
|
<div ref={topAnchorRef} />
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"text-center text-sm text-muted-foreground",
|
"text-center text-sm text-muted-foreground",
|
||||||
|
|
@ -4233,8 +4280,8 @@ export function IssueChatThread({
|
||||||
stoppingRunId={stoppingRunId}
|
stoppingRunId={stoppingRunId}
|
||||||
interruptingQueuedRunId={interruptingQueuedRunId}
|
interruptingQueuedRunId={interruptingQueuedRunId}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{showComposer ? (
|
{showComposer ? (
|
||||||
<div data-testid="issue-chat-thread-notices" className="space-y-2">
|
<div data-testid="issue-chat-thread-notices" className="space-y-2">
|
||||||
<IssueAssignedBacklogNotice
|
<IssueAssignedBacklogNotice
|
||||||
|
|
@ -4258,18 +4305,29 @@ export function IssueChatThread({
|
||||||
<IssueAssigneePausedNotice agent={assignedAgent} />
|
<IssueAssigneePausedNotice agent={assignedAgent} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div ref={bottomAnchorRef} />
|
|
||||||
{showComposer ? (
|
{showComposer ? (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
data-testid="issue-chat-bottom-spacer"
|
data-testid="issue-chat-bottom-spacer"
|
||||||
style={{ height: bottomSpacerHeight }}
|
style={{ height: 0 }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</IssueChatErrorBoundary>
|
</IssueChatErrorBoundary>
|
||||||
|
|
||||||
|
{resolvedShowJumpToLatest && newestFirst ? (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleJumpToLatest}
|
||||||
|
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
Jump to latest
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showComposer ? (
|
{showComposer ? (
|
||||||
<div
|
<div
|
||||||
ref={composerViewportAnchorRef}
|
ref={composerViewportAnchorRef}
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,7 @@ export function InstanceExperimentalSettings() {
|
||||||
|
|
||||||
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
||||||
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
||||||
|
const enableNewestFirstIssueThread = experimentalQuery.data?.enableNewestFirstIssueThread === true;
|
||||||
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
||||||
const enableIssueGraphLivenessAutoRecovery =
|
const enableIssueGraphLivenessAutoRecovery =
|
||||||
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
|
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
|
||||||
|
|
@ -298,6 +299,25 @@ export function InstanceExperimentalSettings() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-border bg-card p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-sm font-semibold">Enable Newest-First Issue Thread</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Show issue comments and messages with the newest activity first, move the jump control to the bottom of
|
||||||
|
the page, and surface plain comment timestamps in the header area.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={enableNewestFirstIssueThread}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
toggleMutation.mutate({ enableNewestFirstIssueThread: !enableNewestFirstIssueThread })}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
aria-label="Toggle newest-first issue thread experimental setting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-card p-5">
|
<section className="rounded-xl border border-border bg-card p-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ const mockProjectsApi = vi.hoisted(() => ({
|
||||||
|
|
||||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||||
getGeneral: vi.fn(),
|
getGeneral: vi.fn(),
|
||||||
|
getExperimental: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||||
|
|
@ -192,6 +193,7 @@ vi.mock("../components/InlineEditor", () => ({
|
||||||
|
|
||||||
vi.mock("../components/IssueChatThread", () => ({
|
vi.mock("../components/IssueChatThread", () => ({
|
||||||
IssueChatThread: (props: {
|
IssueChatThread: (props: {
|
||||||
|
newestFirst?: boolean;
|
||||||
onWorkModeChange?: (workMode: string) => void;
|
onWorkModeChange?: (workMode: string) => void;
|
||||||
issueWorkMode?: string;
|
issueWorkMode?: string;
|
||||||
onStopRun?: (runId: string) => Promise<void>;
|
onStopRun?: (runId: string) => Promise<void>;
|
||||||
|
|
@ -804,6 +806,9 @@ describe("IssueDetail", () => {
|
||||||
keyboardShortcuts: false,
|
keyboardShortcuts: false,
|
||||||
feedbackDataSharingPreference: "prompt",
|
feedbackDataSharingPreference: "prompt",
|
||||||
});
|
});
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||||
|
enableNewestFirstIssueThread: false,
|
||||||
|
});
|
||||||
mockIssuesListRender.mockClear();
|
mockIssuesListRender.mockClear();
|
||||||
mockIssueChatThreadRender.mockClear();
|
mockIssueChatThreadRender.mockClear();
|
||||||
});
|
});
|
||||||
|
|
@ -839,6 +844,45 @@ describe("IssueDetail", () => {
|
||||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes oldest-first thread mode when the experimental flag is disabled", async () => {
|
||||||
|
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<IssueDetail />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||||
|
newestFirst: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes newest-first thread mode when the experimental flag is enabled", async () => {
|
||||||
|
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||||
|
enableNewestFirstIssueThread: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<IssueDetail />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||||
|
newestFirst: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("passes blocker attention to the issue detail header status icon", async () => {
|
it("passes blocker attention to the issue detail header status icon", async () => {
|
||||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||||
status: "blocked",
|
status: "blocked",
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,7 @@ type IssueDetailChatTabProps = {
|
||||||
issueId: string;
|
issueId: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
|
newestFirstIssueThreadEnabled: boolean;
|
||||||
issueStatus: Issue["status"];
|
issueStatus: Issue["status"];
|
||||||
issueWorkMode: IssueWorkMode;
|
issueWorkMode: IssueWorkMode;
|
||||||
executionRunId: string | null;
|
executionRunId: string | null;
|
||||||
|
|
@ -655,6 +656,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||||
issueId,
|
issueId,
|
||||||
companyId,
|
companyId,
|
||||||
projectId,
|
projectId,
|
||||||
|
newestFirstIssueThreadEnabled,
|
||||||
issueWorkMode,
|
issueWorkMode,
|
||||||
issueStatus,
|
issueStatus,
|
||||||
executionRunId,
|
executionRunId,
|
||||||
|
|
@ -855,6 +857,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||||
) : null}
|
) : null}
|
||||||
<IssueChatThread
|
<IssueChatThread
|
||||||
composerRef={composerRef}
|
composerRef={composerRef}
|
||||||
|
newestFirst={newestFirstIssueThreadEnabled}
|
||||||
comments={commentsWithRunMeta}
|
comments={commentsWithRunMeta}
|
||||||
interactions={interactions}
|
interactions={interactions}
|
||||||
feedbackVotes={feedbackVotes}
|
feedbackVotes={feedbackVotes}
|
||||||
|
|
@ -1315,6 +1318,12 @@ export function IssueDetail() {
|
||||||
});
|
});
|
||||||
const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun;
|
const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun;
|
||||||
const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun;
|
const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun;
|
||||||
|
const { data: experimentalSettings } = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
placeholderData: keepPreviousDataForSameQueryTail(issueId ?? "pending"),
|
||||||
|
});
|
||||||
|
const newestFirstIssueThreadEnabled = experimentalSettings?.enableNewestFirstIssueThread === true;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasLiveRuns && locallyQueuedCommentRunIds.size > 0) {
|
if (!hasLiveRuns && locallyQueuedCommentRunIds.size > 0) {
|
||||||
setLocallyQueuedCommentRunIds(new Map());
|
setLocallyQueuedCommentRunIds(new Map());
|
||||||
|
|
@ -3781,6 +3790,7 @@ export function IssueDetail() {
|
||||||
issueId={issue.id}
|
issueId={issue.id}
|
||||||
companyId={issue.companyId}
|
companyId={issue.companyId}
|
||||||
projectId={issue.projectId ?? null}
|
projectId={issue.projectId ?? null}
|
||||||
|
newestFirstIssueThreadEnabled={newestFirstIssueThreadEnabled}
|
||||||
issueStatus={issue.status}
|
issueStatus={issue.status}
|
||||||
issueWorkMode={issue.workMode ?? "standard"}
|
issueWorkMode={issue.workMode ?? "standard"}
|
||||||
executionRunId={issue.executionRunId ?? null}
|
executionRunId={issue.executionRunId ?? null}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue