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 {...@@ -3,7 +3,10 @@ import {
3 appendCodexArtifactTemplateUsePrompt,3 appendCodexArtifactTemplateUsePrompt,
4 type CodexArtifactTemplate,4 type CodexArtifactTemplate,
5} from "@t3tools/client-runtime/codex-artifact-templates";5} 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";
7import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";10import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
8import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";11import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
9import type { LegendListRef } from "@legendapp/list/react-native";12import type { LegendListRef } from "@legendapp/list/react-native";
...@@ -72,6 +75,7 @@ import type {...@@ -72,6 +75,7 @@ import type {
72 ThreadFeedEntry,75 ThreadFeedEntry,
73} from "../../lib/threadActivity";76} from "../../lib/threadActivity";
74import { PendingApprovalCard } from "./PendingApprovalCard";77import { PendingApprovalCard } from "./PendingApprovalCard";
78import { ComposerFeedback } from "./ComposerFeedback";
75import { ComposerUsageLimits } from "./ComposerUsageLimits";79import { ComposerUsageLimits } from "./ComposerUsageLimits";
76import { PendingUserInputCard } from "./PendingUserInputCard";80import { PendingUserInputCard } from "./PendingUserInputCard";
77import {81import {
...@@ -101,6 +105,8 @@ export interface ThreadDetailScreenProps {...@@ -101,6 +105,8 @@ export interface ThreadDetailScreenProps {
101 readonly screenTone: StatusTone;105 readonly screenTone: StatusTone;
102 readonly connectionError: string | null;106 readonly connectionError: string | null;
103 readonly environmentLabel: string | null;107 readonly environmentLabel: string | null;
108 readonly feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>;
109 readonly onDismissFeedback: (id: MessageId) => void;
104 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;110 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
105 readonly activeWorkStartedAt: string | null;111 readonly activeWorkStartedAt: string | null;
106 readonly isCompacting: boolean;112 readonly isCompacting: boolean;
...@@ -847,6 +853,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -847,6 +853,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
847 onScrollToEnd={handleScrollToEnd}853 onScrollToEnd={handleScrollToEnd}
848 />854 />
849 <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>855 <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 ))}
850 {usageLimitsReport && activeUserInputRequestId === null ? (863 {usageLimitsReport && activeUserInputRequestId === null ? (
851 <Animated.View864 <Animated.View
852 className="shrink-0 px-4 pb-3"865 className="shrink-0 px-4 pb-3"
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+2
...@@ -787,6 +787,8 @@ function ThreadRouteContent(...@@ -787,6 +787,8 @@ function ThreadRouteContent(
787 screenTone={connectionTone(routeConnectionState)}787 screenTone={connectionTone(routeConnectionState)}
788 connectionError={routeConnectionError}788 connectionError={routeConnectionError}
789 environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null}789 environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null}
790 feedbackSubmissions={composer.feedbackSubmissions}
791 onDismissFeedback={composer.dismissFeedback}
790 selectedThreadFeed={composer.selectedThreadFeed}792 selectedThreadFeed={composer.selectedThreadFeed}
791 activeWorkStartedAt={composer.activeWorkStartedAt}793 activeWorkStartedAt={composer.activeWorkStartedAt}
792 isCompacting={composer.isCompacting}794 isCompacting={composer.isCompacting}
apps/mobile/src/lib/threadActivity.test.ts-67
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";1import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
3import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
43
5import {4import {
6 EventId,5 EventId,
...@@ -27,34 +26,6 @@ import {...@@ -27,34 +26,6 @@ import {
27 type WorkLogEntry,26 type WorkLogEntry,
28} from "./threadActivity";27} 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
58const singleSelectQuestion = {29const singleSelectQuestion = {
59 id: "runtime",30 id: "runtime",
60 header: "Runtime",31 header: "Runtime",
...@@ -879,44 +850,6 @@ describe("buildThreadFeed", () => {...@@ -879,44 +850,6 @@ describe("buildThreadFeed", () => {
879 },850 },
880 );851 );
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
920 it("keeps historic work entries attributed to their turns", () => {853 it("keeps historic work entries attributed to their turns", () => {
921 const thread = makeThread({854 const thread = makeThread({
922 id: ThreadId.make("thread-1"),855 id: ThreadId.make("thread-1"),
apps/mobile/src/state/use-thread-composer-state.ts+22-39
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useEffect, useMemo, useState } from "react";2import { useCallback, useEffect, useMemo, useState } from "react";
3import { Alert } from "react-native";3import { Alert } from "react-native";
4import * as Cause from "effect/Cause";
54
6import {5import {
7 CommandId,6 CommandId,
...@@ -16,12 +15,10 @@ import {...@@ -16,12 +15,10 @@ import {
16} from "@t3tools/contracts";15} from "@t3tools/contracts";
17import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";16import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
18import {17import {
19 codexFeedbackMessage,
20 parseCodexFeedbackCommand,18 parseCodexFeedbackCommand,
21 submitCodexFeedback,19 submitCodexFeedback,
22 type CodexFeedbackSubmission,20 type CodexFeedbackSubmission,
23} from "@t3tools/client-runtime/state/threads";21} from "@t3tools/client-runtime/state/threads";
24import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
25import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";22import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
2623
27import { makeQueuedMessageMetadata } from "../lib/commandMetadata";24import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
...@@ -35,7 +32,6 @@ import {...@@ -35,7 +32,6 @@ import {
35} from "../lib/composerImages";32} from "../lib/composerImages";
36import type { DraftComposerImageAttachment } from "../lib/composerImages";33import type { DraftComposerImageAttachment } from "../lib/composerImages";
37import { scopedThreadKey } from "../lib/scopedEntities";34import { scopedThreadKey } from "../lib/scopedEntities";
38import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
39import { buildThreadFeed } from "../lib/threadActivity";35import { buildThreadFeed } from "../lib/threadActivity";
40import { appAtomRegistry } from "../state/atom-registry";36import { appAtomRegistry } from "../state/atom-registry";
41import {37import {
...@@ -126,27 +122,31 @@ export function useThreadComposerState() {...@@ -126,27 +122,31 @@ export function useThreadComposerState() {
126 () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),122 () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
127 [queuedMessagesByThreadKey, selectedThreadKey],123 [queuedMessagesByThreadKey, selectedThreadKey],
128 );124 );
129 const localFeedbackMessages = useMemo(() => {125 const feedbackSubmissions = useMemo(
130 const submissions = selectedThreadKey126 () => (selectedThreadKey ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) : []),
131 ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? [])127 [feedbackSubmissionsByThreadKey, selectedThreadKey],
132 : [];128 );
133 return submissions.flatMap((submission) =>129 const dismissFeedback = useCallback(
134 submission.status === "interrupted"130 (id: MessageId) => {
135 ? []131 if (!selectedThreadKey) return;
136 : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")],132 setFeedbackSubmissionsByThreadKey((current) => ({
137 );133 ...current,
138 }, [feedbackSubmissionsByThreadKey, selectedThreadKey]);134 [selectedThreadKey]: (current[selectedThreadKey] ?? []).filter((entry) => entry.id !== id),
135 }));
136 },
137 [selectedThreadKey],
138 );
139 const selectedThreadMessages = selectedThreadDetail?.messages;139 const selectedThreadMessages = selectedThreadDetail?.messages;
140 const selectedThreadActivities = selectedThreadDetail?.activities;140 const selectedThreadActivities = selectedThreadDetail?.activities;
141 const selectedThreadFeed = useMemo(141 const selectedThreadFeed = useMemo(
142 () =>142 () =>
143 selectedThreadMessages && selectedThreadActivities143 selectedThreadMessages && selectedThreadActivities
144 ? buildThreadFeed(144 ? buildThreadFeed({
145 { messages: selectedThreadMessages, activities: selectedThreadActivities },145 messages: selectedThreadMessages,
146 { localMessages: localFeedbackMessages },146 activities: selectedThreadActivities,
147 )147 })
148 : [],148 : [],
149 [localFeedbackMessages, selectedThreadActivities, selectedThreadMessages],149 [selectedThreadActivities, selectedThreadMessages],
150 );150 );
151151
152 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;152 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
...@@ -294,7 +294,7 @@ export function useThreadComposerState() {...@@ -294,7 +294,7 @@ export function useThreadComposerState() {
294 return null;294 return null;
295 }295 }
296 const metadata = makeQueuedMessageMetadata();296 const metadata = makeQueuedMessageMetadata();
297 const result = await submitCodexFeedback({297 await submitCodexFeedback({
298 submission: {298 submission: {
299 id: MessageId.make(metadata.messageId),299 id: MessageId.make(metadata.messageId),
300 command: text,300 command: text,
...@@ -322,25 +322,6 @@ export function useThreadComposerState() {...@@ -322,25 +322,6 @@ export function useThreadComposerState() {
322 },322 },
323 }),323 }),
324 });324 });
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 ]);
344 return null;325 return null;
345 }326 }
346327
...@@ -573,6 +554,8 @@ export function useThreadComposerState() {...@@ -573,6 +554,8 @@ export function useThreadComposerState() {
573 );554 );
574555
575 return {556 return {
557 feedbackSubmissions,
558 dismissFeedback,
576 selectedThreadFeed,559 selectedThreadFeed,
577 selectedThreadQueueCount,560 selectedThreadQueueCount,
578 activeWorkStartedAt,561 activeWorkStartedAt,
apps/web/src/components/ChatView.tsx+22-49
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 hasProviderUsageLimits,5 hasProviderUsageLimits,
6 isUsageLimitsCommand,6 isUsageLimitsCommand,
7} from "@t3tools/shared/usageLimits";7} from "@t3tools/shared/usageLimits";
8import { feedbackBannerItem } from "./chat/ComposerFeedback";
8import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";9import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
9import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";10import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests";
10import {11import {
...@@ -40,7 +41,6 @@ import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";...@@ -40,7 +41,6 @@ import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors";
40import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";41import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
41import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";42import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
42import {43import {
43 codexFeedbackMessage,
44 parseCodexFeedbackCommand,44 parseCodexFeedbackCommand,
45 submitCodexFeedback,45 submitCodexFeedback,
46 type CodexFeedbackSubmission,46 type CodexFeedbackSubmission,
...@@ -3027,14 +3027,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3027,14 +3027,7 @@ export default function ChatView(props: ChatViewProps) {
3027 });3027 });
3028 });3028 });
30293029
3030 const localMessages = [3030 const localMessages = optimisticUserMessages;
3031 ...optimisticUserMessages,
3032 ...feedbackSubmissions.flatMap((submission) =>
3033 submission.status === "interrupted"
3034 ? []
3035 : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")],
3036 ),
3037 ];
3038 if (localMessages.length === 0) {3031 if (localMessages.length === 0) {
3039 return serverMessagesWithPreviewHandoff;3032 return serverMessagesWithPreviewHandoff;
3040 }3033 }
...@@ -3047,7 +3040,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -3047,7 +3040,6 @@ export default function ChatView(props: ChatViewProps) {
3047 }, [3040 }, [
3048 attachmentPreviewHandoffByMessageId,3041 attachmentPreviewHandoffByMessageId,
3049 displayServerMessages,3042 displayServerMessages,
3050 feedbackSubmissions,
3051 optimisticUserMessages,3043 optimisticUserMessages,
3052 projectHandoffMessagePreviews,3044 projectHandoffMessagePreviews,
3053 ]);3045 ]);
...@@ -5624,6 +5616,21 @@ export default function ChatView(props: ChatViewProps) {...@@ -5624,6 +5616,21 @@ export default function ChatView(props: ChatViewProps) {
5624 }5616 }
5625 void handleSwitchCheckoutToThread();5617 void handleSwitchCheckoutToThread();
5626 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);5618 }, [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 );
5627 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {5634 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
5628 const backgroundLivenessItems =5635 const backgroundLivenessItems =
5629 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];5636 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
...@@ -5635,6 +5642,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5635,6 +5642,7 @@ export default function ChatView(props: ChatViewProps) {
5635 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];5642 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
5636 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {5643 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
5637 return [5644 return [
5645 ...feedbackBannerItems,
5638 ...usageLimitsItems,5646 ...usageLimitsItems,
5639 ...systemComposerBannerItems,5647 ...systemComposerBannerItems,
5640 ...backgroundLivenessItems,5648 ...backgroundLivenessItems,
...@@ -5644,6 +5652,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5644,6 +5652,7 @@ export default function ChatView(props: ChatViewProps) {
5644 ];5652 ];
5645 }5653 }
5646 return [5654 return [
5655 ...feedbackBannerItems,
5647 ...usageLimitsItems,5656 ...usageLimitsItems,
5648 ...systemComposerBannerItems,5657 ...systemComposerBannerItems,
5649 ...backgroundLivenessItems,5658 ...backgroundLivenessItems,
...@@ -5692,6 +5701,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5692,6 +5701,7 @@ export default function ChatView(props: ChatViewProps) {
5692 }, [5701 }, [
5693 activeBranchMismatchKey,5702 activeBranchMismatchKey,
5694 backgroundLivenessBannerItem,5703 backgroundLivenessBannerItem,
5704 feedbackBannerItems,
5695 handleRestoreThreadBranch,5705 handleRestoreThreadBranch,
5696 isRestoringThreadBranch,5706 isRestoringThreadBranch,
5697 localCheckoutBranchMismatch,5707 localCheckoutBranchMismatch,
...@@ -6276,7 +6286,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6276,7 +6286,7 @@ export default function ChatView(props: ChatViewProps) {
6276 return;6286 return;
6277 }6287 }
6278 feedbackUploadsInFlightRef.current.add(routeThreadKey);6288 feedbackUploadsInFlightRef.current.add(routeThreadKey);
6279 const result = await submitCodexFeedback({6289 await submitCodexFeedback({
6280 submission: {6290 submission: {
6281 id: newMessageId(),6291 id: newMessageId(),
6282 command: trimmed,6292 command: trimmed,
...@@ -6286,7 +6296,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -6286,7 +6296,6 @@ export default function ChatView(props: ChatViewProps) {
6286 promptRef.current = "";6296 promptRef.current = "";
6287 clearComposerDraftContent(composerDraftTarget);6297 clearComposerDraftContent(composerDraftTarget);
6288 composerRef.current?.resetCursorState();6298 composerRef.current?.resetCursorState();
6289 scrollToEnd();
6290 },6299 },
6291 onUpdate: (submission) => {6300 onUpdate: (submission) => {
6292 setFeedbackSubmissionsByThreadKey((current) => {6301 setFeedbackSubmissionsByThreadKey((current) => {
...@@ -6311,43 +6320,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6311,43 +6320,7 @@ export default function ChatView(props: ChatViewProps) {
6311 }).finally(() => {6320 }).finally(() => {
6312 feedbackUploadsInFlightRef.current.delete(routeThreadKey);6321 feedbackUploadsInFlightRef.current.delete(routeThreadKey);
6313 });6322 });
6314 if (result._tag === "Failure") {6323
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 );
6351 return;6324 return;
6352 }6325 }
6353 if (6326 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 @@...@@ -1,5 +1,4 @@
1import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";1import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
2import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads";
3import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";2import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react";
4import { renderToStaticMarkup } from "react-dom/server";3import { renderToStaticMarkup } from "react-dom/server";
5import { create, type ReactTestRenderer } from "react-test-renderer";4import { create, type ReactTestRenderer } from "react-test-renderer";
...@@ -328,61 +327,6 @@ describe("MessagesTimeline", () => {...@@ -328,61 +327,6 @@ describe("MessagesTimeline", () => {
328 },327 },
329 );328 );
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
386 it("renders elapsed time for a completed turn", () => {330 it("renders elapsed time for a completed turn", () => {
387 const turnId = TurnId.make("turn-with-fold");331 const turnId = TurnId.make("turn-with-fold");
388 const assistantEntry = buildAssistantTimelineEntry("Done.");332 const assistantEntry = buildAssistantTimelineEntry("Done.");
packages/client-runtime/src/state/threadFeedback.test.ts+8-10
...@@ -4,7 +4,7 @@ import * as Cause from "effect/Cause";...@@ -4,7 +4,7 @@ import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/unstable/reactivity";4import { AsyncResult } from "effect/unstable/reactivity";
55
6import {6import {
7 codexFeedbackMessage,7 codexFeedbackNotice,
8 parseCodexFeedbackCommand,8 parseCodexFeedbackCommand,
9 submitCodexFeedback,9 submitCodexFeedback,
10 type CodexFeedbackSubmission,10 type CodexFeedbackSubmission,
...@@ -40,7 +40,7 @@ describe("submitCodexFeedback", () => {...@@ -40,7 +40,7 @@ describe("submitCodexFeedback", () => {
40 createdAt: "2026-08-23T00:00:00.000Z",40 createdAt: "2026-08-23T00:00:00.000Z",
41 } as const;41 } 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 () => {
44 let draft: string = submission.command;44 let draft: string = submission.command;
45 let finishUpload:45 let finishUpload:
46 | ((result: ReturnType<typeof AsyncResult.success<{ feedbackId: string }>>) => void)46 | ((result: ReturnType<typeof AsyncResult.success<{ feedbackId: string }>>) => void)
...@@ -66,14 +66,10 @@ describe("submitCodexFeedback", () => {...@@ -66,14 +66,10 @@ describe("submitCodexFeedback", () => {
6666
67 expect(draft).toBe("");67 expect(draft).toBe("");
68 expect(states).toEqual([{ ...submission, status: "uploading" }]);68 expect(states).toEqual([{ ...submission, status: "uploading" }]);
69 expect(codexFeedbackMessage(states[0]!)).toMatchObject({69 expect(codexFeedbackNotice(states[0]!)).toEqual({
70 id: submission.id,70 title: "Sending feedback to OpenAI...",
71 role: "user",71 description: undefined,
72 text: submission.command,
73 });72 });
74 expect(codexFeedbackMessage(states[0]!, "assistant").text).toBe(
75 "Sending feedback to OpenAI...",
76 );
7773
78 draft = "Keep this newer message.";74 draft = "Keep this newer message.";
79 finishUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" }));75 finishUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" }));
...@@ -85,7 +81,7 @@ describe("submitCodexFeedback", () => {...@@ -85,7 +81,7 @@ describe("submitCodexFeedback", () => {
85 status: "sent",81 status: "sent",
86 feedbackId: "codex-thread-1",82 feedbackId: "codex-thread-1",
87 });83 });
88 expect(codexFeedbackMessage(states.at(-1)!, "assistant").text).toContain("codex-thread-1");84 expect(codexFeedbackNotice(states.at(-1)!)?.description).toContain("codex-thread-1");
89 });85 });
9086
91 it("records a failed upload without losing its user-facing error", async () => {87 it("records a failed upload without losing its user-facing error", async () => {
...@@ -105,6 +101,7 @@ describe("submitCodexFeedback", () => {...@@ -105,6 +101,7 @@ describe("submitCodexFeedback", () => {
105 status: "failed",101 status: "failed",
106 errorMessage: "Upload rejected.",102 errorMessage: "Upload rejected.",
107 });103 });
104 expect(codexFeedbackNotice(states.at(-1)!)?.description).toBe("Upload rejected.");
108 });105 });
109106
110 it("marks interruptions without reporting them as upload failures", async () => {107 it("marks interruptions without reporting them as upload failures", async () => {
...@@ -119,6 +116,7 @@ describe("submitCodexFeedback", () => {...@@ -119,6 +116,7 @@ describe("submitCodexFeedback", () => {
119 });116 });
120117
121 expect(states.at(-1)).toEqual({ ...submission, status: "interrupted" });118 expect(states.at(-1)).toEqual({ ...submission, status: "interrupted" });
119 expect(codexFeedbackNotice(states.at(-1)!)).toBeNull();
122 });120 });
123121
124 it("lets another feedback submission finish while the first remains in flight", async () => {122 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 @@...@@ -1,8 +1,4 @@
1import {1import type { MessageId, ProviderUploadFeedbackResult } from "@t3tools/contracts";
2 MessageId,
3 type OrchestrationMessage,
4 type ProviderUploadFeedbackResult,
5} from "@t3tools/contracts";
62
7import {3import {
8 isAtomCommandInterrupted,4 isAtomCommandInterrupted,
...@@ -32,28 +28,20 @@ export function parseCodexFeedbackCommand(text: string): { readonly reason?: str...@@ -32,28 +28,20 @@ export function parseCodexFeedbackCommand(text: string): { readonly reason?: str
32 return reason ? { reason } : {};28 return reason ? { reason } : {};
33}29}
3430
35export function codexFeedbackMessage(31export function codexFeedbackNotice(submission: CodexFeedbackSubmission) {
36 submission: CodexFeedbackSubmission,32 switch (submission.status) {
37 role: "user" | "assistant" = "user",33 case "interrupted":
38): OrchestrationMessage {34 return null;
39 const text =35 case "uploading":
40 role === "user"36 return { title: "Sending feedback to OpenAI...", description: undefined };
41 ? submission.command37 case "sent":
42 : submission.status === "sent"38 return {
43 ? `Feedback sent to OpenAI.\n\nThread ID: \`${submission.feedbackId}\``39 title: "Feedback sent to OpenAI",
44 : submission.status === "failed"40 description: `Thread ID: ${submission.feedbackId}`,
45 ? `Could not send feedback to OpenAI.\n\n${submission.errorMessage}`41 };
46 : "Sending feedback to OpenAI...";42 case "failed":
4743 return { title: "Could not send feedback to OpenAI", description: submission.errorMessage };
48 return {44 }
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 };
57}45}
5846
59export async function submitCodexFeedback<E>(input: {47export async function submitCodexFeedback<E>(input: {