feat(chat): show the user's request decision live in My Chat (core#2636) #2643

Merged
devops-engineer merged 2 commits from feat/2636-decision-chip-in-mychat into main 2026-06-12 13:06:00 +00:00
6 changed files with 152 additions and 0 deletions
+33
View File
@@ -13,6 +13,8 @@ import { AgentCommsPanel } from "./chat/AgentCommsPanel";
import { ChatErrorBanner } from "./chat/ChatErrorBanner";
import { appendActivityLine } from "./chat/activityLog";
import { ToolTraceChips } from "./chat/ToolTraceChips";
import { decisionForChip, decisionChipText } from "./chat/decisionChip";
import { fetchSession } from "@/lib/auth";
import { runtimeDisplayName } from "@/lib/runtime-names";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useChatHistory } from "./chat/hooks/useChatHistory";
@@ -126,6 +128,17 @@ function MyChatPanel({ workspaceId, data }: Props) {
const hasInitialScrollRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
// Current user id, resolved the SAME way RequestsInbox sets responder_id
// ("admin" placeholder when no session). Gates the decision chip to the
// user's OWN responses (core#2636, CR2 fix).
const currentUserIdRef = useRef<string>("admin");
useEffect(() => {
let cancelled = false;
fetchSession()
.then((sess) => { if (!cancelled && sess?.user_id) currentUserIdRef.current = sess.user_id; })
.catch(() => {});
return () => { cancelled = true; };
}, []);
const pasteCounterRef = useRef(0);
const history = useChatHistory(workspaceId, containerRef);
@@ -149,6 +162,14 @@ function MyChatPanel({ workspaceId, data }: Props) {
if (!sending) return;
setActivityLog((prev) => appendActivityLine(prev, entry));
},
onRequestResponded: (p) => {
const decision = decisionForChip(p, currentUserIdRef.current);
if (!decision) return;
history.setMessages((prev) => [
...prev,
{ ...createMessage("system", decisionChipText(decision, p.title)), decision },
]);
},
onSendComplete: () => {
if (sendingFromAPIRef.current) {
releaseSendGuards();
@@ -480,6 +501,17 @@ function MyChatPanel({ workspaceId, data }: Props) {
</div>
)}
{history.messages.map((msg) => (
msg.decision ? (
<div key={msg.id} className="flex justify-center my-1">
<div className={`text-[10px] px-2 py-0.5 rounded-full border ${
msg.decision === "rejected"
? "text-bad border-bad/40 bg-bad/10"
: "text-good border-good/40 bg-good/10 dark:text-good"
}`}>
{msg.decision === "rejected" ? "✕" : "✓"} {msg.content}
</div>
</div>
) : (
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
@@ -610,6 +642,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
</div>
</div>
</div>
)
))}
{/* Thinking indicator — shows when this tab is awaiting a reply
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { decisionForChip, decisionChipText } from "../decisionChip";
const base = { status: "approved", responderType: "user", responderId: "u-me", title: "Test approval", kind: "approval" };
describe("decisionForChip gate (core#2636, CR2: only the user's OWN response)", () => {
it("renders for the current user's own approval", () => {
expect(decisionForChip(base, "u-me")).toBe("approved");
});
it("IGNORES a different user's response (no 'You' chip for someone else)", () => {
expect(decisionForChip({ ...base, responderId: "u-other" }, "u-me")).toBeNull();
});
it("ignores agent-side responses", () => {
expect(decisionForChip({ ...base, responderType: "agent", responderId: "u-me" }, "u-me")).toBeNull();
});
it("ignores when responderId is empty (fail closed — never mis-attribute)", () => {
expect(decisionForChip({ ...base, responderId: "" }, "u-me")).toBeNull();
});
it("maps rejected and done; ignores unknown status", () => {
expect(decisionForChip({ ...base, status: "rejected" }, "u-me")).toBe("rejected");
expect(decisionForChip({ ...base, status: "done" }, "u-me")).toBe("done");
expect(decisionForChip({ ...base, status: "cancelled" }, "u-me")).toBeNull();
});
it("single-user 'admin' placeholder path matches on both sides", () => {
expect(decisionForChip({ ...base, responderId: "admin" }, "admin")).toBe("approved");
});
});
describe("decisionChipText", () => {
it("formats with title", () => {
expect(decisionChipText("approved", "Ship it")).toContain("approved");
expect(decisionChipText("approved", "Ship it")).toContain("Ship it");
});
it("formats rejected/done verbs and the no-title fallback", () => {
expect(decisionChipText("rejected", "")).toBe("You rejected the request");
expect(decisionChipText("done", "")).toBe("You completed the request");
});
});
@@ -0,0 +1,45 @@
import type { ChatMessage } from "./types";
/** The REQUEST_RESPONDED payload the decision chip consumes. */
export interface RequestRespondedPayload {
status: string;
responderType: string;
responderId: string;
title: string;
kind: string;
}
/** Decide whether a REQUEST_RESPONDED event should render a decision chip in
* the CURRENT user's My Chat, and which decision it is. Returns null when no
* chip should show.
*
* Gate (core#2636, CR2 fix): only the user's OWN responses — never an agent
* response, and never another user's response in a multi-user org (showing
* someone else's decision as "You …" is wrong + a confusion/privacy risk).
* currentUserId is resolved the SAME way RequestsInbox sets responder_id
* (session user_id, "admin" placeholder when no session), so the ids match
* on the single-user path and correctly diverge per-user otherwise.
*/
export function decisionForChip(
p: RequestRespondedPayload,
currentUserId: string,
): ChatMessage["decision"] | null {
if (p.responderType !== "user") return null;
if (!p.responderId || p.responderId !== currentUserId) return null;
switch (p.status) {
case "approved":
return "approved";
case "rejected":
return "rejected";
case "done":
return "done";
default:
return null;
}
}
/** The chip's human label for a decision + optional request title. */
export function decisionChipText(decision: NonNullable<ChatMessage["decision"]>, title: string): string {
const verb = decision === "approved" ? "approved" : decision === "rejected" ? "rejected" : "completed";
return `You ${verb}${title ? `${title}` : " the request"}`;
}
@@ -10,6 +10,15 @@ export interface UseChatSocketCallbacks {
onActivityLog?: (entry: string) => void;
onSendComplete?: () => void;
onSendError?: (error: string) => void;
/** A request the user (or an agent) responded to — drives the live
* decision chip in My Chat (core#2636). */
onRequestResponded?: (p: {
status: string;
responderType: string;
responderId: string;
title: string;
kind: string;
}) => void;
}
export function useChatSocket(
@@ -125,6 +134,18 @@ export function useChatSocket(
if (task) {
callbacksRef.current.onActivityLog?.(`${task}`);
}
} else if (
msg.event === "REQUEST_RESPONDED" &&
msg.workspace_id === workspaceId
) {
const p = msg.payload || {};
callbacksRef.current.onRequestResponded?.({
status: (p.status as string) || "",
responderType: (p.responder_type as string) || "",
responderId: (p.responder_id as string) || "",
title: (p.title as string) || "",
kind: (p.kind as string) || "",
});
}
} catch {
/* ignore */
+5
View File
@@ -29,6 +29,11 @@ export interface ChatMessage {
attachments?: ChatAttachment[];
/** Tool-use chain for an agent turn (rehydrated from tool_trace). */
toolTrace?: ToolTraceEntry[];
/** Set when this message is a user DECISION on a request (approve /
* reject / mark-done) rather than a chat turn — rendered as a centered
* chip in My Chat so the action is visible the moment it happens
* (core#2636). */
decision?: "approved" | "rejected" | "done";
timestamp: string; // ISO string for serialization
}
@@ -465,6 +465,11 @@ func (s *RequestStore) Respond(ctx context.Context, id, action, responderType, r
"status": status,
"responder_type": responderType,
"responder_id": responderID,
// title + kind let the canvas render a live decision line in My
// Chat ("You approved 'X'") the moment a user responds, instead
// of the decision being invisible until a reload (core#2636).
"title": req.Title,
"kind": req.Kind,
}); err != nil {
log.Printf("request: failed to broadcast responded for %s: %v", target, err)
}