Fix multi-user issue chat identities

This commit is contained in:
Dotta committed 2026-04-15 21:23:48 -05:00
1 parent c1b77e4696
commit 6427ad5feb
5 files changed
+227 -94

No files matched your search

+35 -1
View File
@@ -5,7 +5,12 @@ 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, canStopIssueChatRun, resolveAssistantMessageFoldedState } from "./IssueChatThread";
import {
IssueChatThread,
canStopIssueChatRun,
resolveAssistantMessageFoldedState,
resolveIssueChatHumanAuthor,
} from "./IssueChatThread";
const { markdownEditorFocusMock } = vi.hoisted(() => ({
markdownEditorFocusMock: vi.fn(),
@@ -661,4 +666,33 @@ describe("IssueChatThread", () => {
activeRunIds: new Set<string>(),
})).toBe(false);
});
it("uses company profile data to distinguish the current user from other humans", () => {
const userProfileMap = new Map([
["user-1", { label: "Dotta", image: "/avatars/dotta.png" }],
["user-2", { label: "Alice", image: "/avatars/alice.png" }],
]);
expect(resolveIssueChatHumanAuthor({
authorName: "You",
authorUserId: "user-1",
currentUserId: "user-1",
userProfileMap,
})).toEqual({
isCurrentUser: true,
authorName: "Dotta",
avatarUrl: "/avatars/dotta.png",
});
expect(resolveIssueChatHumanAuthor({
authorName: "Alice",
authorUserId: "user-2",
currentUserId: "user-1",
userProfileMap,
})).toEqual({
isCurrentUser: false,
authorName: "Alice",
avatarUrl: "/avatars/alice.png",
});
});
});
+162 -92
View File
@@ -47,7 +47,7 @@ import {
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
@@ -74,6 +74,7 @@ import {
shouldPreserveComposerViewport,
} from "../lib/issue-chat-scroll";
import { formatAssigneeUserLabel } from "../lib/assignees";
import type { CompanyUserProfile } from "../lib/company-members";
import { timeAgo } from "../lib/timeAgo";
import {
describeToolInput,
@@ -97,6 +98,7 @@ interface IssueChatMessageContext {
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
activeRunIds: ReadonlySet<string>;
onVote?: (
commentId: string,
@@ -219,6 +221,7 @@ interface IssueChatThreadProps {
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
@@ -498,6 +501,26 @@ function initialsForName(name: string) {
return name.slice(0, 2).toUpperCase();
}
export function resolveIssueChatHumanAuthor(args: {
authorName?: string | null;
authorUserId?: string | null;
currentUserId?: string | null;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
}) {
const { authorName, authorUserId, currentUserId, userProfileMap } = args;
const profile = authorUserId ? userProfileMap?.get(authorUserId) ?? null : null;
const isCurrentUser = Boolean(authorUserId && currentUserId && authorUserId === currentUserId);
const resolvedAuthorName = profile?.label?.trim()
|| authorName?.trim()
|| (authorUserId === "local-board" ? "Board" : (isCurrentUser ? "You" : "User"));
return {
isCurrentUser,
authorName: resolvedAuthorName,
avatarUrl: profile?.image ?? null,
};
}
function formatRunStatusLabel(status: string) {
switch (status) {
case "timed_out":
@@ -909,108 +932,152 @@ function IssueChatToolPart({
}
function IssueChatUserMessage() {
const { onInterruptQueued, onCancelQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
const {
onInterruptQueued,
onCancelQueued,
interruptingQueuedRunId,
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;
const authorName = typeof custom.authorName === "string" ? custom.authorName : null;
const authorUserId = typeof custom.authorUserId === "string" ? custom.authorUserId : null;
const queued = custom.queueState === "queued" || custom.clientStatus === "queued";
const pending = custom.clientStatus === "pending";
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
const [copied, setCopied] = useState(false);
const {
isCurrentUser,
authorName: resolvedAuthorName,
avatarUrl,
} = resolveIssueChatHumanAuthor({
authorName,
authorUserId,
currentUserId,
userProfileMap,
});
const authorAvatar = (
<Avatar size="sm" className="mt-1 shrink-0">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={resolvedAuthorName} /> : null}
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
</Avatar>
);
const messageBody = (
<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")}>
<span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span>
</div>
<div
className={cn(
"min-w-0 break-all rounded-2xl px-4 py-2.5",
queued
? "bg-amber-50/80 dark:bg-amber-500/10"
: "bg-muted",
pending && "opacity-80",
)}
>
{queued ? (
<div className="mb-1.5 flex items-center gap-2">
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
Queued
</span>
{queueTargetRunId && onInterruptQueued ? (
<Button
size="sm"
variant="outline"
className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
disabled={interruptingQueuedRunId === queueTargetRunId}
onClick={() => void onInterruptQueued(queueTargetRunId)}
>
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
</Button>
) : null}
{onCancelQueued ? (
<Button
size="sm"
variant="outline"
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
onClick={() => onCancelQueued(commentId)}
>
Cancel
</Button>
) : null}
</div>
) : null}
<div className="space-y-3">
<MessagePrimitive.Parts
components={{
Text: ({ text }) => <IssueChatTextPart text={text} />,
}}
/>
</div>
</div>
{pending ? (
<div className={cn("mt-1 flex px-1 text-[11px] text-muted-foreground", isCurrentUser ? "justify-end" : "justify-start")}>
Sending...
</div>
) : (
<div
className={cn(
"mt-1 flex items-center gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100",
isCurrentUser ? "justify-end" : "justify-start",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{message.createdAt ? formatDateTime(message.createdAt) : ""}
</TooltipContent>
</Tooltip>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy message"
aria-label="Copy message"
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).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
)}
</div>
);
return (
<MessagePrimitive.Root id={anchorId}>
<div className="group flex items-start justify-end gap-2.5">
<div className="flex min-w-0 max-w-[85%] flex-col items-end">
<div
className={cn(
"min-w-0 break-all rounded-2xl px-4 py-2.5",
queued
? "bg-amber-50/80 dark:bg-amber-500/10"
: "bg-muted",
pending && "opacity-80",
)}
>
{queued ? (
<div className="mb-1.5 flex items-center gap-2">
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
Queued
</span>
{queueTargetRunId && onInterruptQueued ? (
<Button
size="sm"
variant="outline"
className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
disabled={interruptingQueuedRunId === queueTargetRunId}
onClick={() => void onInterruptQueued(queueTargetRunId)}
>
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
</Button>
) : null}
{onCancelQueued ? (
<Button
size="sm"
variant="outline"
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
onClick={() => onCancelQueued(commentId)}
>
Cancel
</Button>
) : null}
</div>
) : null}
<div className="space-y-3">
<MessagePrimitive.Parts
components={{
Text: ({ text }) => <IssueChatTextPart text={text} />,
}}
/>
</div>
</div>
{pending ? (
<div className="mt-1 flex justify-end px-1 text-[11px] text-muted-foreground">Sending...</div>
) : (
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
>
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{message.createdAt ? formatDateTime(message.createdAt) : ""}
</TooltipContent>
</Tooltip>
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy message"
aria-label="Copy message"
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).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
)}
</div>
<Avatar size="sm" className="mt-1 shrink-0">
<AvatarFallback>You</AvatarFallback>
</Avatar>
<div className={cn("group flex items-start gap-2.5", isCurrentUser && "justify-end")}>
{isCurrentUser ? (
<>
{messageBody}
{authorAvatar}
</>
) : (
<>
{authorAvatar}
{messageBody}
</>
)}
</div>
</MessagePrimitive.Root>
);
@@ -1859,6 +1926,7 @@ export function IssueChatThread({
agentMap,
currentUserId,
userLabelMap,
userProfileMap,
onVote,
onAdd,
onCancelRun,
@@ -2035,6 +2103,7 @@ export function IssueChatThread({
agentMap,
currentUserId,
userLabelMap,
userProfileMap,
activeRunIds,
onVote,
onStopRun,
@@ -2051,6 +2120,7 @@ export function IssueChatThread({
agentMap,
currentUserId,
userLabelMap,
userProfileMap,
activeRunIds,
onVote,
onStopRun,
+21
View File
@@ -234,6 +234,27 @@ describe("buildAssistantPartsFromTranscript", () => {
});
describe("buildIssueChatMessages", () => {
it("uses the company user label for current-user comments instead of collapsing to You", () => {
const messages = buildIssueChatMessages({
comments: [createComment({ authorUserId: "user-1" })],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
userLabelMap: new Map([["user-1", "Dotta"]]),
});
expect(messages[0]).toMatchObject({
role: "user",
metadata: {
custom: {
authorName: "Dotta",
authorUserId: "user-1",
},
},
});
});
it("orders events before comments and appends active live runs as running assistant messages", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
const comments = [
+5 -1
View File
@@ -275,7 +275,11 @@ function authorNameForComment(
if (comment.authorAgentId) {
return agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8);
}
return formatAssigneeUserLabel(comment.authorUserId ?? null, currentUserId, userLabelMap) ?? "You";
const authorUserId = comment.authorUserId ?? null;
if (!authorUserId) return "You";
const userLabel = userLabelMap?.get(authorUserId)?.trim();
if (userLabel) return userLabel;
return formatAssigneeUserLabel(authorUserId, currentUserId, userLabelMap) ?? "You";
}
function formatStatusLabel(status: string) {
+4
View File
@@ -504,6 +504,7 @@ type IssueDetailChatTabProps = {
agentMap: Map<string, Agent>;
currentUserId: string | null;
userLabelMap: ReadonlyMap<string, string> | null;
userProfileMap: ReadonlyMap<string, import("../lib/company-members").CompanyUserProfile> | null;
draftKey: string;
reassignOptions: Array<{ id: string; label: string; searchText?: string }>;
currentAssigneeValue: string;
@@ -538,6 +539,7 @@ function IssueDetailChatTab({
agentMap,
currentUserId,
userLabelMap,
userProfileMap,
draftKey,
reassignOptions,
currentAssigneeValue,
@@ -685,6 +687,7 @@ function IssueDetailChatTab({
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
userProfileMap={userProfileMap}
draftKey={draftKey}
enableReassign
reassignOptions={reassignOptions}
@@ -2583,6 +2586,7 @@ export function IssueDetail() {
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
userProfileMap={userProfileMap}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
reassignOptions={commentReassignOptions}
currentAssigneeValue={actualAssigneeValue}