mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Polish issue chat layout and add UX lab
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
73abe4c76e
commit
3fea60c04c
5 changed files with 724 additions and 25 deletions
|
|
@ -36,6 +36,7 @@ import { PluginManager } from "./pages/PluginManager";
|
|||
import { PluginSettings } from "./pages/PluginSettings";
|
||||
import { AdapterManager } from "./pages/AdapterManager";
|
||||
import { PluginPage } from "./pages/PluginPage";
|
||||
import { IssueChatUxLab } from "./pages/IssueChatUxLab";
|
||||
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
|
||||
import { OrgChart } from "./pages/OrgChart";
|
||||
import { NewAgent } from "./pages/NewAgent";
|
||||
|
|
@ -175,6 +176,7 @@ function boardRoutes() {
|
|||
<Route path="inbox/all" element={<Inbox />} />
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="tests/ux/chat" element={<IssueChatUxLab />} />
|
||||
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
||||
<Route path="instance/settings/adapters" element={<AdapterManager />} />
|
||||
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
||||
|
|
@ -347,6 +349,7 @@ export function App() {
|
|||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="tests/ux/chat" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path=":companyPrefix" element={<Layout />}>
|
||||
{boardRoutes()}
|
||||
|
|
|
|||
123
ui/src/components/IssueChatThread.test.tsx
Normal file
123
ui/src/components/IssueChatThread.test.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueChatThread } from "./IssueChatThread";
|
||||
|
||||
vi.mock("@assistant-ui/react", () => ({
|
||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ThreadPrimitive: {
|
||||
Root: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div data-testid="thread-root" className={className}>{children}</div>
|
||||
),
|
||||
Viewport: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div data-testid="thread-viewport" className={className}>{children}</div>
|
||||
),
|
||||
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Messages: () => <div data-testid="thread-messages" />,
|
||||
},
|
||||
MessagePrimitive: {
|
||||
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Content: () => null,
|
||||
},
|
||||
useAui: () => ({ thread: () => ({ append: vi.fn() }) }),
|
||||
useAuiState: () => false,
|
||||
useMessage: () => ({
|
||||
id: "message",
|
||||
role: "assistant",
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
content: [],
|
||||
metadata: { custom: {} },
|
||||
status: { type: "complete" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./transcript/useLiveRunTranscripts", () => ({
|
||||
useLiveRunTranscripts: () => ({
|
||||
transcriptByRun: new Map(),
|
||||
hasOutputForRun: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownEditor", () => ({
|
||||
MarkdownEditor: () => <textarea aria-label="Issue chat editor" />,
|
||||
}));
|
||||
|
||||
vi.mock("./InlineEntitySelector", () => ({
|
||||
InlineEntitySelector: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./Identity", () => ({
|
||||
Identity: ({ name }: { name: string }) => <span>{name}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("./OutputFeedbackButtons", () => ({
|
||||
OutputFeedbackButtons: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./AgentIconPicker", () => ({
|
||||
AgentIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./StatusBadge", () => ({
|
||||
StatusBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/usePaperclipIssueRuntime", () => ({
|
||||
usePaperclipIssueRuntime: () => ({}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("IssueChatThread", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("drops the count heading and does not use an internal scrollbox", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Jump to latest");
|
||||
expect(container.textContent).not.toContain("Chat (");
|
||||
|
||||
const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null;
|
||||
expect(viewport).not.toBeNull();
|
||||
expect(viewport?.className).not.toContain("overflow-y-auto");
|
||||
expect(viewport?.className).not.toContain("max-h-[70vh]");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
buildIssueChatMessages,
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
type IssueChatTranscriptEntry,
|
||||
} from "../lib/issue-chat-messages";
|
||||
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -70,6 +71,10 @@ interface IssueChatThreadProps {
|
|||
suggestedAssigneeValue?: string;
|
||||
mentions?: MentionOption[];
|
||||
composerDisabledReason?: string | null;
|
||||
showComposer?: boolean;
|
||||
enableLiveTranscriptPolling?: boolean;
|
||||
transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>;
|
||||
hasOutputForRun?: (runId: string) => boolean;
|
||||
}
|
||||
|
||||
const DRAFT_DEBOUNCE_MS = 800;
|
||||
|
|
@ -735,9 +740,14 @@ export function IssueChatThread({
|
|||
suggestedAssigneeValue,
|
||||
mentions = [],
|
||||
composerDisabledReason = null,
|
||||
showComposer = true,
|
||||
enableLiveTranscriptPolling = true,
|
||||
transcriptsByRunId,
|
||||
hasOutputForRun: hasOutputForRunOverride,
|
||||
}: IssueChatThreadProps) {
|
||||
const location = useLocation();
|
||||
const hasScrolledRef = useRef(false);
|
||||
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const displayLiveRuns = useMemo(() => {
|
||||
const deduped = new Map<string, LiveRunForIssue>();
|
||||
for (const run of liveRuns) {
|
||||
|
|
@ -759,7 +769,12 @@ export function IssueChatThread({
|
|||
}
|
||||
return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}, [activeRun, liveRuns]);
|
||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs: displayLiveRuns, companyId });
|
||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||
runs: enableLiveTranscriptPolling ? displayLiveRuns : [],
|
||||
companyId,
|
||||
});
|
||||
const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun;
|
||||
const resolvedHasOutputForRun = hasOutputForRunOverride ?? hasOutputForRun;
|
||||
|
||||
const messages = useMemo(
|
||||
() =>
|
||||
|
|
@ -769,8 +784,8 @@ export function IssueChatThread({
|
|||
linkedRuns,
|
||||
liveRuns,
|
||||
activeRun,
|
||||
transcriptsByRunId: transcriptByRun,
|
||||
hasOutputForRun,
|
||||
transcriptsByRunId: resolvedTranscriptByRun,
|
||||
hasOutputForRun: resolvedHasOutputForRun,
|
||||
companyId,
|
||||
projectId,
|
||||
agentMap,
|
||||
|
|
@ -782,8 +797,8 @@ export function IssueChatThread({
|
|||
linkedRuns,
|
||||
liveRuns,
|
||||
activeRun,
|
||||
transcriptByRun,
|
||||
hasOutputForRun,
|
||||
resolvedTranscriptByRun,
|
||||
resolvedHasOutputForRun,
|
||||
companyId,
|
||||
projectId,
|
||||
agentMap,
|
||||
|
|
@ -819,6 +834,10 @@ export function IssueChatThread({
|
|||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, [location.hash, messages]);
|
||||
|
||||
function handleJumpToLatest() {
|
||||
bottomAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}
|
||||
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
UserMessage: () => <IssueChatUserMessage companyId={companyId} projectId={projectId} />,
|
||||
|
|
@ -845,38 +864,44 @@ export function IssueChatThread({
|
|||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">Chat ({messages.length})</h3>
|
||||
<ThreadPrimitive.ScrollToBottom className="text-xs text-muted-foreground hover:text-foreground">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleJumpToLatest}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Jump to latest
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ThreadPrimitive.Root className="rounded-2xl border border-border bg-background shadow-sm">
|
||||
<ThreadPrimitive.Viewport className="max-h-[70vh] space-y-4 overflow-y-auto px-4 py-4">
|
||||
<ThreadPrimitive.Root className="rounded-[28px] border border-border/70 bg-[linear-gradient(180deg,rgba(15,23,42,0.02),transparent_22%),var(--background)] px-4 py-4 shadow-sm">
|
||||
<ThreadPrimitive.Viewport className="space-y-4">
|
||||
<ThreadPrimitive.Empty>
|
||||
<div className="rounded-2xl border border-dashed border-border bg-card px-6 py-10 text-center text-sm text-muted-foreground">
|
||||
This issue conversation is empty. Start with a message below.
|
||||
</div>
|
||||
</ThreadPrimitive.Empty>
|
||||
<ThreadPrimitive.Messages components={components} />
|
||||
<div ref={bottomAnchorRef} />
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
|
||||
<IssueChatComposer
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentions}
|
||||
agentMap={agentMap}
|
||||
composerDisabledReason={composerDisabledReason}
|
||||
issueStatus={issueStatus}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
{showComposer ? (
|
||||
<IssueChatComposer
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentions}
|
||||
agentMap={agentMap}
|
||||
composerDisabledReason={composerDisabledReason}
|
||||
issueStatus={issueStatus}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
|
|
|
|||
308
ui/src/fixtures/issueChatUxFixtures.ts
Normal file
308
ui/src/fixtures/issueChatUxFixtures.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import type { Agent, FeedbackVote } from "@paperclipai/shared";
|
||||
import type { LiveRunForIssue } from "../api/heartbeats";
|
||||
import type { InlineEntityOption } from "../components/InlineEntitySelector";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
import type {
|
||||
IssueChatComment,
|
||||
IssueChatLinkedRun,
|
||||
IssueChatTranscriptEntry,
|
||||
} from "../lib/issue-chat-messages";
|
||||
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
|
||||
function createAgent(
|
||||
id: string,
|
||||
name: string,
|
||||
icon: string,
|
||||
urlKey: string,
|
||||
): Agent {
|
||||
const now = new Date("2026-04-06T12:00:00.000Z");
|
||||
return {
|
||||
id,
|
||||
companyId: "company-ux",
|
||||
name,
|
||||
urlKey,
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
};
|
||||
}
|
||||
|
||||
function createComment(overrides: Partial<IssueChatComment>): IssueChatComment {
|
||||
return {
|
||||
id: "comment-default",
|
||||
companyId: "company-ux",
|
||||
issueId: "issue-ux",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "",
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const primaryAgent = createAgent("agent-1", "CodexCoder", "code", "codexcoder");
|
||||
const reviewAgent = createAgent("agent-2", "ClaudeFixer", "sparkles", "claudefixer");
|
||||
|
||||
export const issueChatUxAgentMap = new Map<string, Agent>([
|
||||
[primaryAgent.id, primaryAgent],
|
||||
[reviewAgent.id, reviewAgent],
|
||||
]);
|
||||
|
||||
export const issueChatUxMentions: MentionOption[] = [
|
||||
{
|
||||
id: "mention-agent-1",
|
||||
name: primaryAgent.name,
|
||||
kind: "agent",
|
||||
agentId: primaryAgent.id,
|
||||
agentIcon: primaryAgent.icon,
|
||||
},
|
||||
{
|
||||
id: "mention-agent-2",
|
||||
name: reviewAgent.name,
|
||||
kind: "agent",
|
||||
agentId: reviewAgent.id,
|
||||
agentIcon: reviewAgent.icon,
|
||||
},
|
||||
{
|
||||
id: "mention-project-1",
|
||||
name: "Paperclip Board UI",
|
||||
kind: "project",
|
||||
projectId: "project-1",
|
||||
projectColor: "#0f766e",
|
||||
},
|
||||
];
|
||||
|
||||
export const issueChatUxReassignOptions: InlineEntityOption[] = [
|
||||
{
|
||||
id: `agent:${primaryAgent.id}`,
|
||||
label: primaryAgent.name,
|
||||
searchText: `${primaryAgent.name} codex engineer`,
|
||||
},
|
||||
{
|
||||
id: `agent:${reviewAgent.id}`,
|
||||
label: reviewAgent.name,
|
||||
searchText: `${reviewAgent.name} claude reviewer`,
|
||||
},
|
||||
{
|
||||
id: "user:user-1",
|
||||
label: "Board",
|
||||
searchText: "board user",
|
||||
},
|
||||
];
|
||||
|
||||
export const issueChatUxLiveComments: IssueChatComment[] = [
|
||||
createComment({
|
||||
id: "comment-live-user",
|
||||
body: "Ship the issue page as a real chat. Keep the activity feed, but make the assistant flow feel conversational.",
|
||||
createdAt: new Date("2026-04-06T11:55:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T11:55:00.000Z"),
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-live-agent",
|
||||
authorAgentId: primaryAgent.id,
|
||||
authorUserId: null,
|
||||
body: "I swapped the old comment stack for the new assistant-ui thread and kept the existing issue mutations intact.",
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
runId: "run-history-1",
|
||||
runAgentId: primaryAgent.id,
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-live-queued",
|
||||
body: "Can you also make a dedicated review page that shows every chat state side by side?",
|
||||
createdAt: new Date("2026-04-06T12:05:30.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:05:30.000Z"),
|
||||
clientId: "client-queued-1",
|
||||
clientStatus: "queued",
|
||||
queueState: "queued",
|
||||
queueTargetRunId: "run-live-1",
|
||||
}),
|
||||
];
|
||||
|
||||
export const issueChatUxLiveEvents: IssueTimelineEvent[] = [
|
||||
{
|
||||
id: "event-live-1",
|
||||
createdAt: new Date("2026-04-06T11:54:00.000Z"),
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
statusChange: {
|
||||
from: "done",
|
||||
to: "todo",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "event-live-2",
|
||||
createdAt: new Date("2026-04-06T11:54:30.000Z"),
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
assigneeChange: {
|
||||
from: { agentId: null, userId: null },
|
||||
to: { agentId: primaryAgent.id, userId: null },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const issueChatUxLiveRuns: LiveRunForIssue[] = [
|
||||
{
|
||||
id: "run-live-1",
|
||||
status: "running",
|
||||
invocationSource: "manual",
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-04-06T12:04:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-06T12:04:00.000Z",
|
||||
agentId: primaryAgent.id,
|
||||
agentName: primaryAgent.name,
|
||||
adapterType: "codex_local",
|
||||
issueId: "issue-ux",
|
||||
},
|
||||
];
|
||||
|
||||
export const issueChatUxLinkedRuns: IssueChatLinkedRun[] = [
|
||||
{
|
||||
runId: "run-history-1",
|
||||
status: "succeeded",
|
||||
agentId: primaryAgent.id,
|
||||
createdAt: new Date("2026-04-06T11:58:00.000Z"),
|
||||
startedAt: new Date("2026-04-06T11:58:00.000Z"),
|
||||
finishedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
runId: "run-review-1",
|
||||
status: "failed",
|
||||
agentId: reviewAgent.id,
|
||||
createdAt: new Date("2026-04-06T12:31:00.000Z"),
|
||||
startedAt: new Date("2026-04-06T12:31:00.000Z"),
|
||||
finishedAt: new Date("2026-04-06T12:33:00.000Z"),
|
||||
},
|
||||
];
|
||||
|
||||
export const issueChatUxTranscriptsByRunId = new Map<string, readonly IssueChatTranscriptEntry[]>([
|
||||
[
|
||||
"run-live-1",
|
||||
[
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-06T12:04:02.000Z",
|
||||
text: "I am reshaping the issue page so the thread reads like a conversation instead of a run log.",
|
||||
},
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: "2026-04-06T12:04:05.000Z",
|
||||
text: "Need to remove the internal scrollbox first, otherwise the page still feels like a nested console.",
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: "2026-04-06T12:04:08.000Z",
|
||||
name: "read_file",
|
||||
toolUseId: "tool-read-1",
|
||||
input: { path: "ui/src/components/IssueChatThread.tsx" },
|
||||
},
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts: "2026-04-06T12:04:11.000Z",
|
||||
toolUseId: "tool-read-1",
|
||||
content: "Loaded the current chat surface and found the max-h viewport constraint.",
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: "2026-04-06T12:04:14.000Z",
|
||||
name: "apply_patch",
|
||||
toolUseId: "tool-edit-1",
|
||||
input: { file: "ui/src/components/IssueChatThread.tsx", action: "remove scroll pane" },
|
||||
},
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts: "2026-04-06T12:04:22.000Z",
|
||||
toolUseId: "tool-edit-1",
|
||||
content: "Updated layout classes and swapped Jump to latest to page-level scrolling.",
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
kind: "stderr",
|
||||
ts: "2026-04-06T12:04:24.000Z",
|
||||
text: "vite warm-up: rebuilding route chunks",
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
export const issueChatUxReviewComments: IssueChatComment[] = [
|
||||
createComment({
|
||||
id: "comment-review-user",
|
||||
body: "This looks close. Tighten the spacing and keep the composer grounded to the chat surface.",
|
||||
createdAt: new Date("2026-04-06T12:28:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:28:00.000Z"),
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-review-agent",
|
||||
authorAgentId: reviewAgent.id,
|
||||
authorUserId: null,
|
||||
body: [
|
||||
"Adjusted the treatment to feel more like a product conversation.",
|
||||
"",
|
||||
"- Removed the count from the heading",
|
||||
"- Let the page own scrolling",
|
||||
"- Added a dedicated `/tests/ux/chat` review page",
|
||||
].join("\n"),
|
||||
createdAt: new Date("2026-04-06T12:34:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:34:00.000Z"),
|
||||
runId: "run-review-1",
|
||||
runAgentId: reviewAgent.id,
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-review-user-followup",
|
||||
body: "Perfect. I also want to see an empty state and a blocked composer state before we merge.",
|
||||
createdAt: new Date("2026-04-06T12:36:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:36:00.000Z"),
|
||||
}),
|
||||
];
|
||||
|
||||
export const issueChatUxReviewEvents: IssueTimelineEvent[] = [
|
||||
{
|
||||
id: "event-review-1",
|
||||
createdAt: new Date("2026-04-06T12:27:00.000Z"),
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
assigneeChange: {
|
||||
from: { agentId: primaryAgent.id, userId: null },
|
||||
to: { agentId: reviewAgent.id, userId: null },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const issueChatUxFeedbackVotes: FeedbackVote[] = [
|
||||
{
|
||||
id: "feedback-1",
|
||||
companyId: "company-ux",
|
||||
issueId: "issue-ux",
|
||||
targetType: "issue_comment",
|
||||
targetId: "comment-review-agent",
|
||||
authorUserId: "user-1",
|
||||
vote: "up",
|
||||
reason: null,
|
||||
sharedWithLabs: false,
|
||||
sharedAt: null,
|
||||
consentVersion: null,
|
||||
redactionSummary: null,
|
||||
createdAt: new Date("2026-04-06T12:35:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:35:00.000Z"),
|
||||
},
|
||||
];
|
||||
240
ui/src/pages/IssueChatUxLab.tsx
Normal file
240
ui/src/pages/IssueChatUxLab.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { IssueChatThread } from "../components/IssueChatThread";
|
||||
import {
|
||||
issueChatUxAgentMap,
|
||||
issueChatUxFeedbackVotes,
|
||||
issueChatUxLinkedRuns,
|
||||
issueChatUxLiveComments,
|
||||
issueChatUxLiveEvents,
|
||||
issueChatUxLiveRuns,
|
||||
issueChatUxMentions,
|
||||
issueChatUxReassignOptions,
|
||||
issueChatUxReviewComments,
|
||||
issueChatUxReviewEvents,
|
||||
issueChatUxTranscriptsByRunId,
|
||||
} from "../fixtures/issueChatUxFixtures";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Bot, FlaskConical, MessagesSquare, Route, Sparkles, WandSparkles } from "lucide-react";
|
||||
|
||||
const noop = async () => {};
|
||||
|
||||
const highlights = [
|
||||
"Running assistant replies with streamed text, reasoning, tool cards, and noisy notices",
|
||||
"Historical issue events and linked runs rendered inline with the chat timeline",
|
||||
"Queued user messages, settled assistant comments, and feedback controls",
|
||||
"Empty and disabled-composer states without relying on live backend data",
|
||||
];
|
||||
|
||||
function LabSection({
|
||||
id,
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
accentClassName,
|
||||
children,
|
||||
}: {
|
||||
id?: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
accentClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
"rounded-[28px] border border-border/70 bg-background/80 p-4 shadow-[0_24px_60px_rgba(15,23,42,0.08)] sm:p-5",
|
||||
accentClassName,
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="mt-1 text-xl font-semibold tracking-tight">{title}</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueChatUxLab() {
|
||||
const [showComposer, setShowComposer] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-[linear-gradient(135deg,rgba(8,145,178,0.10),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent_44%),var(--background)] shadow-[0_30px_80px_rgba(15,23,42,0.10)]">
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.2fr)_320px]">
|
||||
<div className="p-6 sm:p-7">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-500/25 bg-cyan-500/[0.08] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.24em] text-cyan-700 dark:text-cyan-300">
|
||||
<FlaskConical className="h-3.5 w-3.5" />
|
||||
Chat UX Lab
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight">Issue chat review surface</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
This page exercises the real assistant-ui issue chat with fixture-backed messages. Use it to review
|
||||
spacing, chronology, running states, tool rendering, activity rows, queueing, and composer behavior
|
||||
without needing a live issue in progress.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||
/tests/ux/chat
|
||||
</Badge>
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||
assistant-ui thread
|
||||
</Badge>
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||
fixture-backed live run
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" size="sm" className="rounded-full" onClick={() => setShowComposer((value) => !value)}>
|
||||
{showComposer ? "Hide composer in primary preview" : "Show composer in primary preview"}
|
||||
</Button>
|
||||
<a
|
||||
href="#live-execution"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/80 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Route className="h-3.5 w-3.5" />
|
||||
Jump to live execution preview
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-border/60 bg-background/70 p-6 lg:border-l lg:border-t-0">
|
||||
<div className="mb-4 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<WandSparkles className="h-4 w-4 text-cyan-700 dark:text-cyan-300" />
|
||||
Covered states
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{highlights.map((highlight) => (
|
||||
<div
|
||||
key={highlight}
|
||||
className="rounded-2xl border border-border/70 bg-background/85 px-4 py-3 text-sm text-muted-foreground"
|
||||
>
|
||||
{highlight}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LabSection
|
||||
id="live-execution"
|
||||
eyebrow="Primary preview"
|
||||
title="Live execution thread"
|
||||
description="Shows the fully active state: timeline events, historical run marker, a running assistant reply with reasoning and tools, and a queued follow-up from the user."
|
||||
accentClassName="bg-[linear-gradient(180deg,rgba(6,182,212,0.05),transparent_28%),var(--background)]"
|
||||
>
|
||||
<IssueChatThread
|
||||
comments={issueChatUxLiveComments}
|
||||
linkedRuns={issueChatUxLinkedRuns.slice(0, 1)}
|
||||
timelineEvents={issueChatUxLiveEvents}
|
||||
liveRuns={issueChatUxLiveRuns}
|
||||
issueStatus="todo"
|
||||
agentMap={issueChatUxAgentMap}
|
||||
currentUserId="user-1"
|
||||
onAdd={noop}
|
||||
onVote={noop}
|
||||
onCancelRun={noop}
|
||||
draftKey="issue-chat-ux-lab-primary"
|
||||
enableReassign
|
||||
reassignOptions={issueChatUxReassignOptions}
|
||||
currentAssigneeValue="agent:agent-1"
|
||||
suggestedAssigneeValue="agent:agent-2"
|
||||
mentions={issueChatUxMentions}
|
||||
showComposer={showComposer}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatUxTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => issueChatUxTranscriptsByRunId.has(runId)}
|
||||
/>
|
||||
</LabSection>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<LabSection
|
||||
eyebrow="Settled review"
|
||||
title="Durable comments and feedback"
|
||||
description="Shows the post-run state: assistant comment feedback controls, historical run context, and timeline reassignment without any active stream."
|
||||
accentClassName="bg-[linear-gradient(180deg,rgba(168,85,247,0.05),transparent_26%),var(--background)]"
|
||||
>
|
||||
<IssueChatThread
|
||||
comments={issueChatUxReviewComments}
|
||||
linkedRuns={issueChatUxLinkedRuns.slice(1)}
|
||||
timelineEvents={issueChatUxReviewEvents}
|
||||
feedbackVotes={issueChatUxFeedbackVotes}
|
||||
feedbackTermsUrl="/feedback-terms"
|
||||
issueStatus="in_review"
|
||||
agentMap={issueChatUxAgentMap}
|
||||
currentUserId="user-1"
|
||||
onAdd={noop}
|
||||
onVote={noop}
|
||||
draftKey="issue-chat-ux-lab-review"
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</LabSection>
|
||||
|
||||
<div className="space-y-6">
|
||||
<LabSection
|
||||
eyebrow="Empty thread"
|
||||
title="Empty state and disabled composer"
|
||||
description="Keeps the message area visible even when there is no thread yet, and replaces the composer with an explicit warning when replies are blocked."
|
||||
accentClassName="bg-[linear-gradient(180deg,rgba(245,158,11,0.08),transparent_26%),var(--background)]"
|
||||
>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
issueStatus="done"
|
||||
agentMap={issueChatUxAgentMap}
|
||||
currentUserId="user-1"
|
||||
onAdd={noop}
|
||||
composerDisabledReason="This workspace is closed, so new chat replies are disabled until the issue is reopened."
|
||||
draftKey="issue-chat-ux-lab-empty"
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</LabSection>
|
||||
|
||||
<Card className="gap-4 border-border/70 bg-background/85 py-0">
|
||||
<CardHeader className="px-5 pt-5 pb-0">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<MessagesSquare className="h-4 w-4 text-cyan-700 dark:text-cyan-300" />
|
||||
Review checklist
|
||||
</div>
|
||||
<CardTitle className="text-lg">What to evaluate on this page</CardTitle>
|
||||
<CardDescription>
|
||||
This route should be the fastest way to inspect the chat system before or after tweaks.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-5 pb-5 pt-0 text-sm text-muted-foreground">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||
<div className="mb-1 flex items-center gap-2 font-medium text-foreground">
|
||||
<Bot className="h-4 w-4 text-cyan-700 dark:text-cyan-300" />
|
||||
Message hierarchy
|
||||
</div>
|
||||
Check that user, assistant, and system rows scan differently without feeling like separate products.
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
|
||||
<div className="mb-1 flex items-center gap-2 font-medium text-foreground">
|
||||
<Sparkles className="h-4 w-4 text-cyan-700 dark:text-cyan-300" />
|
||||
Stream polish
|
||||
</div>
|
||||
Watch the live preview for reasoning density, tool expansion behavior, and queued follow-up readability.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue