feat(chat): show the user's request decision live in My Chat (core#2636) #2643
@@ -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 */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user