diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index 6ec5fd77..0338578a 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -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(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("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) { )} {history.messages.map((msg) => ( + msg.decision ? ( +
+
+ {msg.decision === "rejected" ? "✕" : "✓"} {msg.content} +
+
+ ) : (
+ ) ))} {/* Thinking indicator — shows when this tab is awaiting a reply diff --git a/canvas/src/components/tabs/chat/__tests__/decisionChip.test.ts b/canvas/src/components/tabs/chat/__tests__/decisionChip.test.ts new file mode 100644 index 00000000..2687bf3f --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/decisionChip.test.ts @@ -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"); + }); +}); diff --git a/canvas/src/components/tabs/chat/decisionChip.ts b/canvas/src/components/tabs/chat/decisionChip.ts new file mode 100644 index 00000000..2ad89b34 --- /dev/null +++ b/canvas/src/components/tabs/chat/decisionChip.ts @@ -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, title: string): string { + const verb = decision === "approved" ? "approved" : decision === "rejected" ? "rejected" : "completed"; + return `You ${verb}${title ? ` “${title}”` : " the request"}`; +} diff --git a/canvas/src/components/tabs/chat/hooks/useChatSocket.ts b/canvas/src/components/tabs/chat/hooks/useChatSocket.ts index 46fc8561..dbe3bc1e 100644 --- a/canvas/src/components/tabs/chat/hooks/useChatSocket.ts +++ b/canvas/src/components/tabs/chat/hooks/useChatSocket.ts @@ -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 */ diff --git a/canvas/src/components/tabs/chat/types.ts b/canvas/src/components/tabs/chat/types.ts index a615e212..94de21cd 100644 --- a/canvas/src/components/tabs/chat/types.ts +++ b/canvas/src/components/tabs/chat/types.ts @@ -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 } diff --git a/workspace-server/internal/handlers/request_store.go b/workspace-server/internal/handlers/request_store.go index 61320d44..bf6a40ab 100644 --- a/workspace-server/internal/handlers/request_store.go +++ b/workspace-server/internal/handlers/request_store.go @@ -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) }