authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 12:44:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 12:44:41-07:00
log95d99373b74edfb3bfd092c61a15999a95727423
tree7e9cbeee4d674c2aa9304f1a917fc8e481e0c22d
parent98469159dd9e162a9c2f5cc4bbb2fbe89b3c4f67
signature Signed by PGP key B5690EEEBB952194

fix(clients): show feedback results in composer banners (#10398)


10 files changed, 195 insertions(+), 249 deletions(-)

apps/mobile/src/features/threads/ComposerFeedback.tsx created+63
......@@ -0,0 +1,63 @@
1import {
2 codexFeedbackNotice,
3 type CodexFeedbackSubmission,
4} from "@t3tools/client-runtime/state/threads";
5import { Pressable, View } from "react-native";
6
7import { AppText as Text } from "../../components/AppText";
8import { SymbolView } from "../../components/AppSymbol";
9import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
10
11export function ComposerFeedback({
12 submission,
13 onDismiss,
14}: {
15 readonly submission: CodexFeedbackSubmission;
16 readonly onDismiss: () => void;
17}) {
18 const notice = codexFeedbackNotice(submission);
19 if (!notice) return null;
20 return (
21 <View className="px-4 pb-3">
22 <View className="gap-2 rounded-[20px] border-continuous bg-card p-4">
23 <View className="flex-row items-center gap-3">
24 <Text accessibilityLiveRegion="polite" className="min-w-0 flex-1 text-sm text-foreground">
25 {notice.title}
26 </Text>
27 {submission.status !== "uploading" ? (
28 <Pressable
29 accessibilityLabel="Dismiss feedback notice"
30 accessibilityRole="button"
31 hitSlop={12}
32 onPress={onDismiss}
33 className="p-1 active:opacity-60"
34 >
35 <SymbolView
36 name="xmark"
37 size={14}
38 tintColorClassName="accent-icon-muted"
39 type="monochrome"
40 />
41 </Pressable>
42 ) : null}
43 </View>
44 {notice.description ? (
45 <Text selectable className="text-xs text-foreground-muted">
46 {notice.description}
47 </Text>
48 ) : null}
49 {submission.status === "sent" ? (
50 <Pressable
51 accessibilityRole="button"
52 onPress={() =>
53 copyTextWithHaptic(submission.feedbackId, { target: "Codex feedback thread ID" })
54 }
55 className="self-start py-1 active:opacity-60"
56 >
57 <Text className="text-sm text-foreground">Copy ID</Text>
58 </Pressable>
59 ) : null}
60 </View>
61 </View>
62 );
63}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+14-1
......@@ -3,7 +3,10 @@ import {
33 appendCodexArtifactTemplateUsePrompt,
44 type CodexArtifactTemplate,
55} from "@t3tools/client-runtime/codex-artifact-templates";
6import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
6import type {
7 CodexFeedbackSubmission,
8 EnvironmentThreadStatus,
9} from "@t3tools/client-runtime/state/threads";
710import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
811import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
912import type { LegendListRef } from "@legendapp/list/react-native";
......@@ -72,6 +75,7 @@ import type {
7275 ThreadFeedEntry,
7376} from "../../lib/threadActivity";
7477import { PendingApprovalCard } from "./PendingApprovalCard";
78import { ComposerFeedback } from "./ComposerFeedback";
7579import { ComposerUsageLimits } from "./ComposerUsageLimits";
7680import { PendingUserInputCard } from "./PendingUserInputCard";
7781import {
......@@ -101,6 +105,8 @@ export interface ThreadDetailScreenProps {
101105 readonly screenTone: StatusTone;
102106 readonly connectionError: string | null;
103107 readonly environmentLabel: string | null;
108 readonly feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>;
109 readonly onDismissFeedback: (id: MessageId) => void;
104110 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
105111 readonly activeWorkStartedAt: string | null;
106112 readonly isCompacting: boolean;
......@@ -847,6 +853,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
847853 onScrollToEnd={handleScrollToEnd}
848854 />
849855 <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
856 {props.feedbackSubmissions.map((submission) => (
857 <ComposerFeedback
858 key={submission.id}
859 submission={submission}
860 onDismiss={() => props.onDismissFeedback(submission.id)}
861 />
862 ))}
850863 {usageLimitsReport && activeUserInputRequestId === null ? (
851864 <Animated.View
852865 className="shrink-0 px-4 pb-3"
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+2
......@@ -787,6 +787,8 @@ function ThreadRouteContent(
787787 screenTone={connectionTone(routeConnectionState)}
788788 connectionError={routeConnectionError}
789789 environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null}
790 feedbackSubmissions={composer.feedbackSubmissions}
791 onDismissFeedback={composer.dismissFeedback}
790792 selectedThreadFeed={composer.selectedThreadFeed}
791793 activeWorkStartedAt={composer.activeWorkStartedAt}
792794 isCompacting={composer.isCompacting}
apps/mobile/src/lib/threadActivity.test.ts-67
......@@ -1,6 +1,5 @@
11import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
22import { describe, expect, it } from "vite-plus/test";
3import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
43
54import {
65 EventId,
......@@ -27,34 +26,6 @@ import {
2726 type WorkLogEntry,
2827} from "./threadActivity";
2928
30describe("Codex feedback pseudo-messages", () => {
31 it("keeps pending and completed feedback messages in the mobile thread body", () => {
32 const pending = {
33 id: MessageId.make("feedback-command"),
34 command: "/feedback The agent stopped early.",
35 createdAt: "2026-08-23T00:00:00.000Z",
36 status: "uploading" as const,
37 };
38 const entries = [codexFeedbackMessage(pending), codexFeedbackMessage(pending, "assistant")].map(
39 (message) => ({
40 type: "message" as const,
41 id: message.id,
42 createdAt: message.createdAt,
43 message,
44 }),
45 );
46
47 expect(deriveThreadFeedPresentation(entries, null, new Set())).toEqual(entries);
48 expect(entries[1]?.message.text).toBe("Sending feedback to OpenAI...");
49
50 const completed = codexFeedbackMessage(
51 { ...pending, status: "sent", feedbackId: "codex-thread-1" },
52 "assistant",
53 );
54 expect(completed.text).toContain("codex-thread-1");
55 });
56});
57
5829const singleSelectQuestion = {
5930 id: "runtime",
6031 header: "Runtime",
......@@ -879,44 +850,6 @@ describe("buildThreadFeed", () => {
879850 },
880851 );
881852
882 it("keeps older local feedback before newer messages returned by the server", () => {
883 const submission = {
884 id: MessageId.make("feedback-command-ordering"),
885 command: "/feedback The agent stopped early.",
886 createdAt: "2026-08-23T00:00:01.000Z",
887 status: "sent" as const,
888 feedbackId: "codex-thread-1",
889 };
890 const laterMessage = {
891 id: MessageId.make("later-server-message"),
892 role: "assistant" as const,
893 text: "Newer server response",
894 turnId: null,
895 createdAt: "2026-08-23T00:00:02.000Z",
896 updatedAt: "2026-08-23T00:00:02.000Z",
897 streaming: false,
898 };
899 const thread = makeThread({
900 id: ThreadId.make("thread-feedback-ordering"),
901 projectId: ProjectId.make("project-1"),
902 title: "Feedback ordering",
903 messages: [laterMessage],
904 });
905
906 const feed = buildThreadFeed(thread, {
907 localMessages: [
908 codexFeedbackMessage(submission),
909 codexFeedbackMessage(submission, "assistant"),
910 ],
911 });
912
913 expect(feed.map((entry) => entry.id)).toEqual([
914 "feedback-command-ordering",
915 "feedback-command-ordering:feedback",
916 "later-server-message",
917 ]);
918 });
919
920853 it("keeps historic work entries attributed to their turns", () => {
921854 const thread = makeThread({
922855 id: ThreadId.make("thread-1"),
apps/mobile/src/state/use-thread-composer-state.ts+22-39
......@@ -1,7 +1,6 @@
11import { useAtomValue } from "@effect/atom-react";
22import { useCallback, useEffect, useMemo, useState } from "react";
33import { Alert } from "react-native";
4import * as Cause from "effect/Cause";
54
65import {
76 CommandId,
......@@ -16,12 +15,10 @@ import {
1615} from "@t3tools/contracts";
1716import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
1817import {
19 codexFeedbackMessage,
2018 parseCodexFeedbackCommand,
2119 submitCodexFeedback,
2220 type CodexFeedbackSubmission,
2321} from "@t3tools/client-runtime/state/threads";
24import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
2522import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
2623
2724import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
......@@ -35,7 +32,6 @@ import {
3532} from "../lib/composerImages";
3633import type { DraftComposerImageAttachment } from "../lib/composerImages";
3734import { scopedThreadKey } from "../lib/scopedEntities";
38import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
3935import { buildThreadFeed } from "../lib/threadActivity";
4036import { appAtomRegistry } from "../state/atom-registry";
4137import {
......@@ -126,27 +122,31 @@ export function useThreadComposerState() {
126122 () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
127123 [queuedMessagesByThreadKey, selectedThreadKey],
128124 );
129 const localFeedbackMessages = useMemo(() => {
130 const submissions = selectedThreadKey
131 ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? [])
132 : [];
133 return submissions.flatMap((submission) =>
134 submission.status === "interrupted"
135 ? []
136 : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")],
137 );
138 }, [feedbackSubmissionsByThreadKey, selectedThreadKey]);
125 const feedbackSubmissions = useMemo(
126 () => (selectedThreadKey ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) : []),
127 [feedbackSubmissionsByThreadKey, selectedThreadKey],
128 );
129 const dismissFeedback = useCallback(
130 (id: MessageId) => {
131 if (!selectedThreadKey) return;
132 setFeedbackSubmissionsByThreadKey((current) => ({
133 ...current,
134 [selectedThreadKey]: (current[selectedThreadKey] ?? []).filter((entry) => entry.id !== id),
135 }));
136 },
137 [selectedThreadKey],
138 );
139139 const selectedThreadMessages = selectedThreadDetail?.messages;
140140 const selectedThreadActivities = selectedThreadDetail?.activities;
141141 const selectedThreadFeed = useMemo(
142142 () =>
143143 selectedThreadMessages && selectedThreadActivities
144 ? buildThreadFeed(
145 { messages: selectedThreadMessages, activities: selectedThreadActivities },
146 { localMessages: localFeedbackMessages },
147 )
144 ? buildThreadFeed({
145 messages: selectedThreadMessages,
146 activities: selectedThreadActivities,
147 })
148148 : [],
149 [localFeedbackMessages, selectedThreadActivities, selectedThreadMessages],
149 [selectedThreadActivities, selectedThreadMessages],
150150 );
151151
152152 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
......@@ -294,7 +294,7 @@ export function useThreadComposerState() {
294294 return null;
295295 }
296296 const metadata = makeQueuedMessageMetadata();
297 const result = await submitCodexFeedback({
297 await submitCodexFeedback({
298298 submission: {
299299 id: MessageId.make(metadata.messageId),
300300 command: text,
......@@ -322,25 +322,6 @@ export function useThreadComposerState() {
322322 },
323323 }),
324324 });
325 if (result._tag === "Failure") {
326 if (isAtomCommandInterrupted(result)) {
327 return null;
328 }
329 const error = Cause.squash(result.cause);
330 Alert.alert(
331 "Could not send feedback to OpenAI",
332 error instanceof Error ? error.message : "An error occurred.",
333 );
334 return null;
335 }
336 const feedbackId = result.value.feedbackId;
337 Alert.alert("Feedback sent to OpenAI", `Thread ID: ${feedbackId}`, [
338 { text: "OK", style: "cancel" },
339 {
340 text: "Copy ID",
341 onPress: () => copyTextWithHaptic(feedbackId, { target: "Codex feedback thread ID" }),
342 },
343 ]);
344325 return null;
345326 }
346327
......@@ -573,6 +554,8 @@ export function useThreadComposerState() {
573554 );
574555
575556 return {
557 feedbackSubmissions,
558 dismissFeedback,
576559 selectedThreadFeed,
577560 selectedThreadQueueCount,
578561 activeWorkStartedAt,
apps/web/src/components/ChatView.tsx+22-49
......@@ -5,6 +5,7 @@ import {
55 hasProviderUsageLimits,
66 isUsageLimitsCommand,
77} from "@t3tools/shared/usageLimits";
8import { feedbackBannerItem } from "./chat/ComposerFeedback";
89import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
910import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
1011import {
......@@ -40,7 +41,6 @@ import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
4041import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
4142import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
4243import {
43 codexFeedbackMessage,
4444 parseCodexFeedbackCommand,
4545 submitCodexFeedback,
4646 type CodexFeedbackSubmission,
......@@ -3027,14 +3027,7 @@ export default function ChatView(props: ChatViewProps) {
30273027 });
30283028 });
30293029
3030 const localMessages = [
3031 ...optimisticUserMessages,
3032 ...feedbackSubmissions.flatMap((submission) =>
3033 submission.status === "interrupted"
3034 ? []
3035 : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")],
3036 ),
3037 ];
3030 const localMessages = optimisticUserMessages;
30383031 if (localMessages.length === 0) {
30393032 return serverMessagesWithPreviewHandoff;
30403033 }
......@@ -3047,7 +3040,6 @@ export default function ChatView(props: ChatViewProps) {
30473040 }, [
30483041 attachmentPreviewHandoffByMessageId,
30493042 displayServerMessages,
3050 feedbackSubmissions,
30513043 optimisticUserMessages,
30523044 projectHandoffMessagePreviews,
30533045 ]);
......@@ -5624,6 +5616,21 @@ export default function ChatView(props: ChatViewProps) {
56245616 }
56255617 void handleSwitchCheckoutToThread();
56265618 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
5619 const feedbackBannerItems = useMemo(
5620 () =>
5621 feedbackSubmissions.flatMap((submission) => {
5622 const item = feedbackBannerItem(submission, () => {
5623 setFeedbackSubmissionsByThreadKey((current) => ({
5624 ...current,
5625 [routeThreadKey]: (current[routeThreadKey] ?? []).filter(
5626 (entry) => entry.id !== submission.id,
5627 ),
5628 }));
5629 });
5630 return item ? [item] : [];
5631 }),
5632 [feedbackSubmissions, routeThreadKey],
5633 );
56275634 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
56285635 const backgroundLivenessItems =
56295636 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
......@@ -5635,6 +5642,7 @@ export default function ChatView(props: ChatViewProps) {
56355642 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
56365643 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
56375644 return [
5645 ...feedbackBannerItems,
56385646 ...usageLimitsItems,
56395647 ...systemComposerBannerItems,
56405648 ...backgroundLivenessItems,
......@@ -5644,6 +5652,7 @@ export default function ChatView(props: ChatViewProps) {
56445652 ];
56455653 }
56465654 return [
5655 ...feedbackBannerItems,
56475656 ...usageLimitsItems,
56485657 ...systemComposerBannerItems,
56495658 ...backgroundLivenessItems,
......@@ -5692,6 +5701,7 @@ export default function ChatView(props: ChatViewProps) {
56925701 }, [
56935702 activeBranchMismatchKey,
56945703 backgroundLivenessBannerItem,
5704 feedbackBannerItems,
56955705 handleRestoreThreadBranch,
56965706 isRestoringThreadBranch,
56975707 localCheckoutBranchMismatch,
......@@ -6276,7 +6286,7 @@ export default function ChatView(props: ChatViewProps) {
62766286 return;
62776287 }
62786288 feedbackUploadsInFlightRef.current.add(routeThreadKey);
6279 const result = await submitCodexFeedback({
6289 await submitCodexFeedback({
62806290 submission: {
62816291 id: newMessageId(),
62826292 command: trimmed,
......@@ -6286,7 +6296,6 @@ export default function ChatView(props: ChatViewProps) {
62866296 promptRef.current = "";
62876297 clearComposerDraftContent(composerDraftTarget);
62886298 composerRef.current?.resetCursorState();
6289 scrollToEnd();
62906299 },
62916300 onUpdate: (submission) => {
62926301 setFeedbackSubmissionsByThreadKey((current) => {
......@@ -6311,43 +6320,7 @@ export default function ChatView(props: ChatViewProps) {
63116320 }).finally(() => {
63126321 feedbackUploadsInFlightRef.current.delete(routeThreadKey);
63136322 });
6314 if (result._tag === "Failure") {
6315 if (!isAtomCommandInterrupted(result)) {
6316 toastManager.add(
6317 stackedThreadToast({
6318 type: "error",
6319 title: "Could not send feedback to OpenAI",
6320 description: chatActionErrorMessage(squashAtomCommandFailure(result)),
6321 }),
6322 );
6323 }
6324 return;
6325 }
6326 const feedbackId = result.value.feedbackId;
6327 toastManager.add(
6328 stackedThreadToast({
6329 type: "success",
6330 title: "Feedback sent to OpenAI",
6331 description: `Thread ID: ${feedbackId}`,
6332 timeout: 0,
6333 actionProps: {
6334 children: "Copy ID",
6335 onClick: () => {
6336 void writeTextToClipboard(feedbackId, "Codex feedback thread ID").catch(
6337 (error: unknown) => {
6338 toastManager.add(
6339 stackedThreadToast({
6340 type: "error",
6341 title: "Could not copy thread ID",
6342 description: chatActionErrorMessage(error),
6343 }),
6344 );
6345 },
6346 );
6347 },
6348 },
6349 }),
6350 );
6323
63516324 return;
63526325 }
63536326 if (
apps/web/src/components/chat/ComposerFeedback.tsx created+49
......@@ -0,0 +1,49 @@
1import {
2 codexFeedbackNotice,
3 type CodexFeedbackSubmission,
4} from "@t3tools/client-runtime/state/threads";
5import { MessageSquareIcon } from "lucide-react";
6
7import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
8import { Button } from "../ui/button";
9import { toastManager } from "../ui/toast";
10import type { ComposerBannerStackItem } from "./ComposerBannerStack";
11
12export function feedbackBannerItem(
13 submission: CodexFeedbackSubmission,
14 onDismiss: () => void,
15): ComposerBannerStackItem | null {
16 const notice = codexFeedbackNotice(submission);
17 if (!notice) return null;
18 return {
19 id: `feedback:${submission.id}`,
20 variant:
21 submission.status === "failed" ? "error" : submission.status === "sent" ? "success" : "info",
22 priority: submission.status === "uploading" ? "activity" : "notice",
23 icon: <MessageSquareIcon />,
24 ...notice,
25 actions:
26 submission.status === "sent" ? (
27 <Button
28 size="xs"
29 variant="ghost"
30 onClick={() => {
31 void writeTextToClipboard(submission.feedbackId, "Codex feedback thread ID").catch(
32 (error: unknown) => {
33 toastManager.add({
34 type: "error",
35 title: "Could not copy thread ID",
36 description: error instanceof Error ? error.message : "An error occurred.",
37 });
38 },
39 );
40 }}
41 >
42 Copy ID
43 </Button>
44 ) : undefined,
45 ...(submission.status !== "uploading"
46 ? { dismissLabel: "Dismiss feedback notice", onDismiss }
47 : {}),
48 };
49}
apps/web/src/components/chat/MessagesTimeline.test.tsx-56
......@@ -1,5 +1,4 @@
11import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
2import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
32import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";
43import { renderToStaticMarkup } from "react-dom/server";
54import { create, type ReactTestRenderer } from "react-test-renderer";
......@@ -328,61 +327,6 @@ describe("MessagesTimeline", () => {
328327 },
329328 );
330329
331 it("renders a feedback command and its pending response as normal thread messages", () => {
332 const submission = {
333 id: MessageId.make("feedback-command"),
334 command: "/feedback The agent stopped early.",
335 createdAt: MESSAGE_CREATED_AT,
336 status: "uploading" as const,
337 };
338 const messages = [
339 codexFeedbackMessage(submission),
340 codexFeedbackMessage(submission, "assistant"),
341 ];
342 const markup = renderToStaticMarkup(
343 <MessagesTimeline
344 {...buildProps()}
345 timelineEntries={messages.map((message) => ({
346 id: message.id,
347 kind: "message" as const,
348 createdAt: message.createdAt,
349 message,
350 }))}
351 />,
352 );
353
354 expect(markup).toContain("/feedback The agent stopped early.");
355 expect(markup).toContain("Sending feedback to OpenAI...");
356 });
357
358 it("renders the returned Codex thread ID in the feedback response", () => {
359 const submission = {
360 id: MessageId.make("feedback-command"),
361 command: "/feedback The agent stopped early.",
362 createdAt: MESSAGE_CREATED_AT,
363 status: "sent" as const,
364 feedbackId: "codex-thread-1",
365 };
366 const messages = [
367 codexFeedbackMessage(submission),
368 codexFeedbackMessage(submission, "assistant"),
369 ];
370 const markup = renderToStaticMarkup(
371 <MessagesTimeline
372 {...buildProps()}
373 timelineEntries={messages.map((message) => ({
374 id: message.id,
375 kind: "message" as const,
376 createdAt: message.createdAt,
377 message,
378 }))}
379 />,
380 );
381
382 expect(markup).toContain("Feedback sent to OpenAI.");
383 expect(markup).toContain("codex-thread-1");
384 });
385
386330 it("renders elapsed time for a completed turn", () => {
387331 const turnId = TurnId.make("turn-with-fold");
388332 const assistantEntry = buildAssistantTimelineEntry("Done.");
packages/client-runtime/src/state/threadFeedback.test.ts+8-10
......@@ -4,7 +4,7 @@ import * as Cause from "effect/Cause";
44import { AsyncResult } from "effect/unstable/reactivity";
55
66import {
7 codexFeedbackMessage,
7 codexFeedbackNotice,
88 parseCodexFeedbackCommand,
99 submitCodexFeedback,
1010 type CodexFeedbackSubmission,
......@@ -40,7 +40,7 @@ describe("submitCodexFeedback", () => {
4040 createdAt: "2026-08-23T00:00:00.000Z",
4141 } as const;
4242
43 it("shows the command and clears the draft before the upload finishes", async () => {
43 it("reports upload progress and clears the draft before the upload finishes", async () => {
4444 let draft: string = submission.command;
4545 let finishUpload:
4646 | ((result: ReturnType<typeof AsyncResult.success<{ feedbackId: string }>>) => void)
......@@ -66,14 +66,10 @@ describe("submitCodexFeedback", () => {
6666
6767 expect(draft).toBe("");
6868 expect(states).toEqual([{ ...submission, status: "uploading" }]);
69 expect(codexFeedbackMessage(states[0]!)).toMatchObject({
70 id: submission.id,
71 role: "user",
72 text: submission.command,
69 expect(codexFeedbackNotice(states[0]!)).toEqual({
70 title: "Sending feedback to OpenAI...",
71 description: undefined,
7372 });
74 expect(codexFeedbackMessage(states[0]!, "assistant").text).toBe(
75 "Sending feedback to OpenAI...",
76 );
7773
7874 draft = "Keep this newer message.";
7975 finishUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" }));
......@@ -85,7 +81,7 @@ describe("submitCodexFeedback", () => {
8581 status: "sent",
8682 feedbackId: "codex-thread-1",
8783 });
88 expect(codexFeedbackMessage(states.at(-1)!, "assistant").text).toContain("codex-thread-1");
84 expect(codexFeedbackNotice(states.at(-1)!)?.description).toContain("codex-thread-1");
8985 });
9086
9187 it("records a failed upload without losing its user-facing error", async () => {
......@@ -105,6 +101,7 @@ describe("submitCodexFeedback", () => {
105101 status: "failed",
106102 errorMessage: "Upload rejected.",
107103 });
104 expect(codexFeedbackNotice(states.at(-1)!)?.description).toBe("Upload rejected.");
108105 });
109106
110107 it("marks interruptions without reporting them as upload failures", async () => {
......@@ -119,6 +116,7 @@ describe("submitCodexFeedback", () => {
119116 });
120117
121118 expect(states.at(-1)).toEqual({ ...submission, status: "interrupted" });
119 expect(codexFeedbackNotice(states.at(-1)!)).toBeNull();
122120 });
123121
124122 it("lets another feedback submission finish while the first remains in flight", async () => {
packages/client-runtime/src/state/threadFeedback.ts+15-27
......@@ -1,8 +1,4 @@
1import {
2 MessageId,
3 type OrchestrationMessage,
4 type ProviderUploadFeedbackResult,
5} from "@t3tools/contracts";
1import type { MessageId, ProviderUploadFeedbackResult } from "@t3tools/contracts";
62
73import {
84 isAtomCommandInterrupted,
......@@ -32,28 +28,20 @@ export function parseCodexFeedbackCommand(text: string): { readonly reason?: str
3228 return reason ? { reason } : {};
3329}
3430
35export function codexFeedbackMessage(
36 submission: CodexFeedbackSubmission,
37 role: "user" | "assistant" = "user",
38): OrchestrationMessage {
39 const text =
40 role === "user"
41 ? submission.command
42 : submission.status === "sent"
43 ? `Feedback sent to OpenAI.\n\nThread ID: \`${submission.feedbackId}\``
44 : submission.status === "failed"
45 ? `Could not send feedback to OpenAI.\n\n${submission.errorMessage}`
46 : "Sending feedback to OpenAI...";
47
48 return {
49 id: role === "user" ? submission.id : MessageId.make(`${submission.id}:feedback`),
50 role,
51 text,
52 turnId: null,
53 streaming: false,
54 createdAt: submission.createdAt,
55 updatedAt: submission.createdAt,
56 };
31export function codexFeedbackNotice(submission: CodexFeedbackSubmission) {
32 switch (submission.status) {
33 case "interrupted":
34 return null;
35 case "uploading":
36 return { title: "Sending feedback to OpenAI...", description: undefined };
37 case "sent":
38 return {
39 title: "Feedback sent to OpenAI",
40 description: `Thread ID: ${submission.feedbackId}`,
41 };
42 case "failed":
43 return { title: "Could not send feedback to OpenAI", description: submission.errorMessage };
44 }
5745}
5846
5947export async function submitCodexFeedback<E>(input: {