mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
[codex] Polish issue and operator workflow UI (#4090)
## Thinking Path > - Paperclip operators spend much of their time in issues, inboxes, selectors, and rich comment threads. > - Small interaction problems in those surfaces slow down supervision of AI-agent work. > - The branch included related operator quality-of-life fixes for issue layout, inbox actions, recent selectors, mobile inputs, and chat rendering stability. > - These changes are UI-focused and can land independently from workspace navigation and access-profile work. > - This pull request groups the operator QoL fixes into one standalone branch. > - The benefit is a more stable and efficient board workflow for issue triage and task editing. ## What Changed - Widened issue detail content and added a desktop inbox archive action. - Fixed mobile text-field zoom by keeping touch input font sizes at 16px. - Prioritized recent picker selections for assignees/projects in issue and routine flows. - Showed actionable approvals in the Mine inbox model. - Fixed issue chat renderer state crashes and hardened tests. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run ui/src/components/IssueChatThread.test.tsx ui/src/lib/inbox.test.ts ui/src/lib/recent-selections.test.ts` - Split integration check: merged last after the other [PAP-1614](/PAP/issues/PAP-1614) branches with no merge conflicts. - Confirmed this branch does not include `pnpm-lock.yaml`. ## Risks - Low to medium risk: mostly UI state, layout, and selection-priority behavior. - Visual layout and mobile zoom behavior may need browser/device QA beyond component tests. - No database migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5.4 tool-enabled coding model, agentic code-editing/runtime with local shell and GitHub CLI access; exact context window and reasoning mode are not exposed by the Paperclip harness. ## 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 - [x] If this change affects the UI, I have included before/after screenshots - [x] 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 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
fee514efcb
commit
057fee4836
19 changed files with 596 additions and 275 deletions
|
|
@ -1,13 +1,13 @@
|
|||
import {
|
||||
AssistantRuntimeProvider,
|
||||
ActionBarPrimitive,
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
useAui,
|
||||
useAuiState,
|
||||
useMessage,
|
||||
} from "@assistant-ui/react";
|
||||
import type { ToolCallMessagePart } from "@assistant-ui/react";
|
||||
import type {
|
||||
ReasoningMessagePart,
|
||||
TextMessagePart,
|
||||
ThreadMessage,
|
||||
ToolCallMessagePart,
|
||||
} from "@assistant-ui/react";
|
||||
import {
|
||||
createContext,
|
||||
Component,
|
||||
|
|
@ -257,7 +257,7 @@ interface IssueChatThreadProps {
|
|||
|
||||
type IssueChatErrorBoundaryProps = {
|
||||
resetKey: string;
|
||||
messages: readonly import("@assistant-ui/react").ThreadMessage[];
|
||||
messages: readonly ThreadMessage[];
|
||||
emptyMessage: string;
|
||||
variant: "full" | "embedded";
|
||||
children: ReactNode;
|
||||
|
|
@ -301,7 +301,7 @@ class IssueChatErrorBoundary extends Component<IssueChatErrorBoundaryProps, Issu
|
|||
}
|
||||
}
|
||||
|
||||
function fallbackAuthorLabel(message: import("@assistant-ui/react").ThreadMessage) {
|
||||
function fallbackAuthorLabel(message: ThreadMessage) {
|
||||
const custom = message.metadata?.custom as Record<string, unknown> | undefined;
|
||||
if (typeof custom?.["authorName"] === "string") return custom["authorName"];
|
||||
if (typeof custom?.["runAgentName"] === "string") return custom["runAgentName"];
|
||||
|
|
@ -310,7 +310,7 @@ function fallbackAuthorLabel(message: import("@assistant-ui/react").ThreadMessag
|
|||
return "System";
|
||||
}
|
||||
|
||||
function fallbackTextParts(message: import("@assistant-ui/react").ThreadMessage) {
|
||||
function fallbackTextParts(message: ThreadMessage) {
|
||||
const contentLines: string[] = [];
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text" || part.type === "reasoning") {
|
||||
|
|
@ -337,7 +337,7 @@ function IssueChatFallbackThread({
|
|||
emptyMessage,
|
||||
variant,
|
||||
}: {
|
||||
messages: readonly import("@assistant-ui/react").ThreadMessage[];
|
||||
messages: readonly ThreadMessage[];
|
||||
emptyMessage: string;
|
||||
variant: "full" | "embedded";
|
||||
}) {
|
||||
|
|
@ -574,9 +574,16 @@ function cleanToolDisplayText(tool: ToolCallMessagePart): string {
|
|||
return summary ? `${name} ${summary}` : name;
|
||||
}
|
||||
|
||||
function IssueChatChainOfThought() {
|
||||
type IssueChatCoTPart = ReasoningMessagePart | ToolCallMessagePart;
|
||||
|
||||
function IssueChatChainOfThought({
|
||||
message,
|
||||
cotParts,
|
||||
}: {
|
||||
message: ThreadMessage;
|
||||
cotParts: readonly IssueChatCoTPart[];
|
||||
}) {
|
||||
const { agentMap } = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
||||
const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null;
|
||||
|
|
@ -584,8 +591,6 @@ function IssueChatChainOfThought() {
|
|||
const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined;
|
||||
const isMessageRunning = message.role === "assistant" && message.status?.type === "running";
|
||||
|
||||
const cotParts = useAuiState((s) => s.chainOfThought?.parts ?? []) as ReadonlyArray<{ type: string; text?: string; toolName?: string; toolCallId?: string; args?: unknown; argsText?: string; result?: unknown; isError?: boolean }>;
|
||||
|
||||
const myIndex = useMemo(
|
||||
() => findCoTSegmentIndex(message.content, cotParts),
|
||||
[message.content, cotParts],
|
||||
|
|
@ -931,7 +936,103 @@ function IssueChatToolPart({
|
|||
);
|
||||
}
|
||||
|
||||
function IssueChatUserMessage() {
|
||||
function getThreadMessageCopyText(message: ThreadMessage) {
|
||||
return message.content
|
||||
.filter((part): part is TextMessagePart => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function IssueChatTextParts({
|
||||
message,
|
||||
recessed = false,
|
||||
}: {
|
||||
message: ThreadMessage;
|
||||
recessed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{message.content
|
||||
.filter((part): part is TextMessagePart => part.type === "text")
|
||||
.map((part, index) => (
|
||||
<IssueChatTextPart
|
||||
key={`${message.id}:text:${index}`}
|
||||
text={part.text}
|
||||
recessed={recessed}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function groupAssistantParts(
|
||||
content: readonly ThreadMessage["content"][number][],
|
||||
): Array<
|
||||
| { type: "text"; part: TextMessagePart; index: number }
|
||||
| { type: "cot"; parts: IssueChatCoTPart[]; startIndex: number }
|
||||
> {
|
||||
const groups: Array<
|
||||
| { type: "text"; part: TextMessagePart; index: number }
|
||||
| { type: "cot"; parts: IssueChatCoTPart[]; startIndex: number }
|
||||
> = [];
|
||||
let pendingCoT: IssueChatCoTPart[] = [];
|
||||
let pendingStartIndex = -1;
|
||||
|
||||
const flushCoT = () => {
|
||||
if (pendingCoT.length === 0) return;
|
||||
groups.push({ type: "cot", parts: pendingCoT, startIndex: pendingStartIndex });
|
||||
pendingCoT = [];
|
||||
pendingStartIndex = -1;
|
||||
};
|
||||
|
||||
content.forEach((part, index) => {
|
||||
if (part.type === "reasoning" || part.type === "tool-call") {
|
||||
if (pendingCoT.length === 0) pendingStartIndex = index;
|
||||
pendingCoT.push(part);
|
||||
return;
|
||||
}
|
||||
flushCoT();
|
||||
if (part.type === "text") {
|
||||
groups.push({ type: "text", part, index });
|
||||
}
|
||||
});
|
||||
flushCoT();
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function IssueChatAssistantParts({
|
||||
message,
|
||||
hasCoT,
|
||||
}: {
|
||||
message: ThreadMessage;
|
||||
hasCoT: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{groupAssistantParts(message.content).map((group) => {
|
||||
if (group.type === "text") {
|
||||
return (
|
||||
<IssueChatTextPart
|
||||
key={`${message.id}:text:${group.index}`}
|
||||
text={group.part.text}
|
||||
recessed={hasCoT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<IssueChatChainOfThought
|
||||
key={`${message.id}:cot:${group.startIndex}`}
|
||||
message={message}
|
||||
cotParts={group.parts}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueChatUserMessage({ message }: { message: ThreadMessage }) {
|
||||
const {
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
|
|
@ -939,7 +1040,6 @@ function IssueChatUserMessage() {
|
|||
currentUserId,
|
||||
userProfileMap,
|
||||
} = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id;
|
||||
|
|
@ -1008,11 +1108,7 @@ function IssueChatUserMessage() {
|
|||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 max-w-full space-y-3">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: ({ text }) => <IssueChatTextPart text={text} />,
|
||||
}}
|
||||
/>
|
||||
<IssueChatTextParts message={message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1064,7 +1160,7 @@ function IssueChatUserMessage() {
|
|||
);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root id={anchorId}>
|
||||
<div id={anchorId}>
|
||||
<div className={cn("group flex items-start gap-2.5", isCurrentUser && "justify-end")}>
|
||||
{isCurrentUser ? (
|
||||
<>
|
||||
|
|
@ -1078,11 +1174,11 @@ function IssueChatUserMessage() {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueChatAssistantMessage() {
|
||||
function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
|
||||
const {
|
||||
feedbackVoteByTargetId,
|
||||
feedbackDataSharingPreference,
|
||||
|
|
@ -1093,7 +1189,6 @@ function IssueChatAssistantMessage() {
|
|||
onStopRun,
|
||||
stoppingRunId,
|
||||
} = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
const authorName = typeof custom.authorName === "string"
|
||||
|
|
@ -1120,6 +1215,8 @@ function IssueChatAssistantMessage() {
|
|||
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
||||
const [folded, setFolded] = useState(isFoldable);
|
||||
const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable });
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyText = getThreadMessageCopyText(message);
|
||||
|
||||
// Derive fold state synchronously during render (not in useEffect) so the
|
||||
// browser never paints the un-folded intermediate state — prevents the
|
||||
|
|
@ -1149,7 +1246,7 @@ function IssueChatAssistantMessage() {
|
|||
const activeVote = commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null;
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root id={anchorId}>
|
||||
<div id={anchorId}>
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<Avatar size="sm" className="mt-0.5 shrink-0">
|
||||
{agentIcon ? (
|
||||
|
|
@ -1192,12 +1289,7 @@ function IssueChatAssistantMessage() {
|
|||
{!folded ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: ({ text }) => <IssueChatTextPart text={text} recessed={hasCoT} />,
|
||||
ChainOfThought: IssueChatChainOfThought,
|
||||
}}
|
||||
/>
|
||||
<IssueChatAssistantParts message={message} hasCoT={hasCoT} />
|
||||
{message.content.length === 0 && waitingText ? (
|
||||
<div className="flex items-center gap-2.5 rounded-lg px-1 py-2">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
|
||||
|
|
@ -1225,15 +1317,20 @@ function IssueChatAssistantMessage() {
|
|||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<ActionBarPrimitive.Copy
|
||||
copiedDuration={2000}
|
||||
className="group inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[copied=true]:text-foreground"
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(copyText).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5 group-data-[copied=true]:hidden" />
|
||||
<Check className="hidden h-3.5 w-3.5 group-data-[copied=true]:block" />
|
||||
</ActionBarPrimitive.Copy>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{commentId && onVote ? (
|
||||
<IssueChatFeedbackButtons
|
||||
activeVote={activeVote}
|
||||
|
|
@ -1270,11 +1367,7 @@ function IssueChatAssistantMessage() {
|
|||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const text = message.content
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n\n");
|
||||
void navigator.clipboard.writeText(text);
|
||||
void navigator.clipboard.writeText(copyText);
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
|
|
@ -1307,7 +1400,7 @@ function IssueChatAssistantMessage() {
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1531,9 +1624,8 @@ function IssueChatFeedbackButtons({
|
|||
);
|
||||
}
|
||||
|
||||
function IssueChatSystemMessage() {
|
||||
function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
const { agentMap, currentUserId, userLabelMap } = useContext(IssueChatCtx);
|
||||
const message = useMessage();
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
const runId = typeof custom.runId === "string" ? custom.runId : null;
|
||||
|
|
@ -1601,16 +1693,16 @@ function IssueChatSystemMessage() {
|
|||
|
||||
if (isCurrentUser) {
|
||||
return (
|
||||
<MessagePrimitive.Root id={anchorId}>
|
||||
<div id={anchorId}>
|
||||
<div className="flex items-start justify-end gap-2 py-1">
|
||||
{eventContent}
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root id={anchorId}>
|
||||
<div id={anchorId}>
|
||||
<div className="flex items-start gap-2.5 py-1">
|
||||
<Avatar size="sm" className="mt-0.5">
|
||||
{agentIcon ? (
|
||||
|
|
@ -1623,7 +1715,7 @@ function IssueChatSystemMessage() {
|
|||
{eventContent}
|
||||
</div>
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1631,7 +1723,7 @@ function IssueChatSystemMessage() {
|
|||
const runAgentIcon = runAgentId ? agentMap?.get(runAgentId)?.icon : undefined;
|
||||
if (custom.kind === "run" && runId && runAgentId && displayedRunAgentName && runStatus) {
|
||||
return (
|
||||
<MessagePrimitive.Root id={anchorId}>
|
||||
<div id={anchorId}>
|
||||
<div className="flex items-center gap-2.5 py-1">
|
||||
<Avatar size="sm">
|
||||
{runAgentIcon ? (
|
||||
|
|
@ -1665,7 +1757,7 @@ function IssueChatSystemMessage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2036,7 +2128,7 @@ export function IssueChatThread({
|
|||
userLabelMap,
|
||||
],
|
||||
);
|
||||
const stableMessagesRef = useRef<readonly import("@assistant-ui/react").ThreadMessage[]>([]);
|
||||
const stableMessagesRef = useRef<readonly ThreadMessage[]>([]);
|
||||
const stableMessageCacheRef = useRef<Map<string, StableThreadMessageCacheEntry>>(new Map());
|
||||
const messages = useMemo(() => {
|
||||
const stabilized = stabilizeThreadMessages(
|
||||
|
|
@ -2131,15 +2223,6 @@ export function IssueChatThread({
|
|||
],
|
||||
);
|
||||
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
UserMessage: IssueChatUserMessage,
|
||||
AssistantMessage: IssueChatAssistantMessage,
|
||||
SystemMessage: IssueChatSystemMessage,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const resolvedShowJumpToLatest = showJumpToLatest ?? variant === "full";
|
||||
const resolvedEmptyMessage = emptyMessage
|
||||
?? (variant === "embedded"
|
||||
|
|
@ -2172,9 +2255,12 @@ export function IssueChatThread({
|
|||
emptyMessage={resolvedEmptyMessage}
|
||||
variant={variant}
|
||||
>
|
||||
<ThreadPrimitive.Root className="">
|
||||
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
|
||||
<ThreadPrimitive.Empty>
|
||||
<div data-testid="thread-root">
|
||||
<div
|
||||
data-testid="thread-viewport"
|
||||
className={variant === "embedded" ? "space-y-3" : "space-y-4"}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className={cn(
|
||||
"text-center text-sm text-muted-foreground",
|
||||
variant === "embedded"
|
||||
|
|
@ -2183,11 +2269,23 @@ export function IssueChatThread({
|
|||
)}>
|
||||
{resolvedEmptyMessage}
|
||||
</div>
|
||||
</ThreadPrimitive.Empty>
|
||||
<ThreadPrimitive.Messages components={components} />
|
||||
) : (
|
||||
// Keep transcript rendering independent from assistant-ui's
|
||||
// index-scoped message providers; live transcripts can shrink
|
||||
// or regroup while the runtime still holds stale indices.
|
||||
messages.map((message) => {
|
||||
if (message.role === "user") {
|
||||
return <IssueChatUserMessage key={message.id} message={message} />;
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
return <IssueChatAssistantMessage key={message.id} message={message} />;
|
||||
}
|
||||
return <IssueChatSystemMessage key={message.id} message={message} />;
|
||||
})
|
||||
)}
|
||||
<div ref={bottomAnchorRef} />
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
</div>
|
||||
</div>
|
||||
</IssueChatErrorBoundary>
|
||||
|
||||
{showComposer ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue