| author | |
| committer | |
| log | 95d99373b74edfb3bfd092c61a15999a95727423 |
| tree | 7e9cbeee4d674c2aa9304f1a917fc8e481e0c22d |
| parent | 98469159dd9e162a9c2f5cc4bbb2fbe89b3c4f67 |
| signature | Signed by PGP key B5690EEEBB952194 |
10 files changed, 195 insertions(+), 249 deletions(-)
apps/mobile/src/features/threads/ComposerFeedback.tsx created+63| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | import { | |
| 2 | codexFeedbackNotice, | |
| 3 | type CodexFeedbackSubmission, | |
| 4 | } from "@t3tools/client-runtime/state/threads"; | |
| 5 | import { Pressable, View } from "react-native"; | |
| 6 | ||
| 7 | import { AppText as Text } from "../../components/AppText"; | |
| 8 | import { SymbolView } from "../../components/AppSymbol"; | |
| 9 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | |
| 10 | ||
| 11 | export 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 | 3 | appendCodexArtifactTemplateUsePrompt, |
| 4 | 4 | type CodexArtifactTemplate, |
| 5 | 5 | } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 6 | import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; | |
| 6 | import type { | |
| 7 | CodexFeedbackSubmission, | |
| 8 | EnvironmentThreadStatus, | |
| 9 | } from "@t3tools/client-runtime/state/threads"; | |
| 7 | 10 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; |
| 8 | 11 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; |
| 9 | 12 | import type { LegendListRef } from "@legendapp/list/react-native"; |
| ... | ... | @@ -72,6 +75,7 @@ import type { |
| 72 | 75 | ThreadFeedEntry, |
| 73 | 76 | } from "../../lib/threadActivity"; |
| 74 | 77 | import { PendingApprovalCard } from "./PendingApprovalCard"; |
| 78 | import { ComposerFeedback } from "./ComposerFeedback"; | |
| 75 | 79 | import { ComposerUsageLimits } from "./ComposerUsageLimits"; |
| 76 | 80 | import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 77 | 81 | import { |
| ... | ... | @@ -101,6 +105,8 @@ export interface ThreadDetailScreenProps { |
| 101 | 105 | readonly screenTone: StatusTone; |
| 102 | 106 | readonly connectionError: string | null; |
| 103 | 107 | readonly environmentLabel: string | null; |
| 108 | readonly feedbackSubmissions: ReadonlyArray<CodexFeedbackSubmission>; | |
| 109 | readonly onDismissFeedback: (id: MessageId) => void; | |
| 104 | 110 | readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>; |
| 105 | 111 | readonly activeWorkStartedAt: string | null; |
| 106 | 112 | readonly isCompacting: boolean; |
| ... | ... | @@ -847,6 +853,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 847 | 853 | onScrollToEnd={handleScrollToEnd} |
| 848 | 854 | /> |
| 849 | 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 | 863 | {usageLimitsReport && activeUserInputRequestId === null ? ( |
| 851 | 864 | <Animated.View |
| 852 | 865 | className="shrink-0 px-4 pb-3" |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+2| ... | ... | @@ -787,6 +787,8 @@ function ThreadRouteContent( |
| 787 | 787 | screenTone={connectionTone(routeConnectionState)} |
| 788 | 788 | connectionError={routeConnectionError} |
| 789 | 789 | environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null} |
| 790 | feedbackSubmissions={composer.feedbackSubmissions} | |
| 791 | onDismissFeedback={composer.dismissFeedback} | |
| 790 | 792 | selectedThreadFeed={composer.selectedThreadFeed} |
| 791 | 793 | activeWorkStartedAt={composer.activeWorkStartedAt} |
| 792 | 794 | isCompacting={composer.isCompacting} |
apps/mobile/src/lib/threadActivity.test.ts-67| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; | |
| 4 | 3 | |
| 5 | 4 | import { |
| 6 | 5 | EventId, |
| ... | ... | @@ -27,34 +26,6 @@ import { |
| 27 | 26 | type WorkLogEntry, |
| 28 | 27 | } from "./threadActivity"; |
| 29 | 28 | |
| 30 | describe("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 | ||
| 58 | 29 | const singleSelectQuestion = { |
| 59 | 30 | id: "runtime", |
| 60 | 31 | header: "Runtime", |
| ... | ... | @@ -879,44 +850,6 @@ describe("buildThreadFeed", () => { |
| 879 | 850 | }, |
| 880 | 851 | ); |
| 881 | 852 | |
| 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 | 853 | it("keeps historic work entries attributed to their turns", () => { |
| 921 | 854 | const thread = makeThread({ |
| 922 | 855 | id: ThreadId.make("thread-1"), |
apps/mobile/src/state/use-thread-composer-state.ts+22-39| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | 3 | import { Alert } from "react-native"; |
| 4 | import * as Cause from "effect/Cause"; | |
| 5 | 4 | |
| 6 | 5 | import { |
| 7 | 6 | CommandId, |
| ... | ... | @@ -16,12 +15,10 @@ import { |
| 16 | 15 | } from "@t3tools/contracts"; |
| 17 | 16 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 18 | 17 | import { |
| 19 | codexFeedbackMessage, | |
| 20 | 18 | parseCodexFeedbackCommand, |
| 21 | 19 | submitCodexFeedback, |
| 22 | 20 | type CodexFeedbackSubmission, |
| 23 | 21 | } from "@t3tools/client-runtime/state/threads"; |
| 24 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; | |
| 25 | 22 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; |
| 26 | 23 | |
| 27 | 24 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; |
| ... | ... | @@ -35,7 +32,6 @@ import { |
| 35 | 32 | } from "../lib/composerImages"; |
| 36 | 33 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 37 | 34 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 38 | import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; | |
| 39 | 35 | import { buildThreadFeed } from "../lib/threadActivity"; |
| 40 | 36 | import { appAtomRegistry } from "../state/atom-registry"; |
| 41 | 37 | import { |
| ... | ... | @@ -126,27 +122,31 @@ export function useThreadComposerState() { |
| 126 | 122 | () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), |
| 127 | 123 | [queuedMessagesByThreadKey, selectedThreadKey], |
| 128 | 124 | ); |
| 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 | ); | |
| 139 | 139 | const selectedThreadMessages = selectedThreadDetail?.messages; |
| 140 | 140 | const selectedThreadActivities = selectedThreadDetail?.activities; |
| 141 | 141 | const selectedThreadFeed = useMemo( |
| 142 | 142 | () => |
| 143 | 143 | selectedThreadMessages && selectedThreadActivities |
| 144 | ? buildThreadFeed( | |
| 145 | { messages: selectedThreadMessages, activities: selectedThreadActivities }, | |
| 146 | { localMessages: localFeedbackMessages }, | |
| 147 | ) | |
| 144 | ? buildThreadFeed({ | |
| 145 | messages: selectedThreadMessages, | |
| 146 | activities: selectedThreadActivities, | |
| 147 | }) | |
| 148 | 148 | : [], |
| 149 | [localFeedbackMessages, selectedThreadActivities, selectedThreadMessages], | |
| 149 | [selectedThreadActivities, selectedThreadMessages], | |
| 150 | 150 | ); |
| 151 | 151 | |
| 152 | 152 | const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; |
| ... | ... | @@ -294,7 +294,7 @@ export function useThreadComposerState() { |
| 294 | 294 | return null; |
| 295 | 295 | } |
| 296 | 296 | const metadata = makeQueuedMessageMetadata(); |
| 297 | const result = await submitCodexFeedback({ | |
| 297 | await submitCodexFeedback({ | |
| 298 | 298 | submission: { |
| 299 | 299 | id: MessageId.make(metadata.messageId), |
| 300 | 300 | command: text, |
| ... | ... | @@ -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 | 325 | return null; |
| 345 | 326 | } |
| 346 | 327 | |
| ... | ... | @@ -573,6 +554,8 @@ export function useThreadComposerState() { |
| 573 | 554 | ); |
| 574 | 555 | |
| 575 | 556 | return { |
| 557 | feedbackSubmissions, | |
| 558 | dismissFeedback, | |
| 576 | 559 | selectedThreadFeed, |
| 577 | 560 | selectedThreadQueueCount, |
| 578 | 561 | activeWorkStartedAt, |
apps/web/src/components/ChatView.tsx+22-49| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | hasProviderUsageLimits, |
| 6 | 6 | isUsageLimitsCommand, |
| 7 | 7 | } from "@t3tools/shared/usageLimits"; |
| 8 | import { feedbackBannerItem } from "./chat/ComposerFeedback"; | |
| 8 | 9 | import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; |
| 9 | 10 | import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; |
| 10 | 11 | import { |
| ... | ... | @@ -40,7 +41,6 @@ import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; |
| 40 | 41 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 41 | 42 | import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; |
| 42 | 43 | import { |
| 43 | codexFeedbackMessage, | |
| 44 | 44 | parseCodexFeedbackCommand, |
| 45 | 45 | submitCodexFeedback, |
| 46 | 46 | type CodexFeedbackSubmission, |
| ... | ... | @@ -3027,14 +3027,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3027 | 3027 | }); |
| 3028 | 3028 | }); |
| 3029 | 3029 | |
| 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; | |
| 3038 | 3031 | if (localMessages.length === 0) { |
| 3039 | 3032 | return serverMessagesWithPreviewHandoff; |
| 3040 | 3033 | } |
| ... | ... | @@ -3047,7 +3040,6 @@ export default function ChatView(props: ChatViewProps) { |
| 3047 | 3040 | }, [ |
| 3048 | 3041 | attachmentPreviewHandoffByMessageId, |
| 3049 | 3042 | displayServerMessages, |
| 3050 | feedbackSubmissions, | |
| 3051 | 3043 | optimisticUserMessages, |
| 3052 | 3044 | projectHandoffMessagePreviews, |
| 3053 | 3045 | ]); |
| ... | ... | @@ -5624,6 +5616,21 @@ export default function ChatView(props: ChatViewProps) { |
| 5624 | 5616 | } |
| 5625 | 5617 | void handleSwitchCheckoutToThread(); |
| 5626 | 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 | 5634 | const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => { |
| 5628 | 5635 | const backgroundLivenessItems = |
| 5629 | 5636 | backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; |
| ... | ... | @@ -5635,6 +5642,7 @@ export default function ChatView(props: ChatViewProps) { |
| 5635 | 5642 | const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; |
| 5636 | 5643 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| 5637 | 5644 | return [ |
| 5645 | ...feedbackBannerItems, | |
| 5638 | 5646 | ...usageLimitsItems, |
| 5639 | 5647 | ...systemComposerBannerItems, |
| 5640 | 5648 | ...backgroundLivenessItems, |
| ... | ... | @@ -5644,6 +5652,7 @@ export default function ChatView(props: ChatViewProps) { |
| 5644 | 5652 | ]; |
| 5645 | 5653 | } |
| 5646 | 5654 | return [ |
| 5655 | ...feedbackBannerItems, | |
| 5647 | 5656 | ...usageLimitsItems, |
| 5648 | 5657 | ...systemComposerBannerItems, |
| 5649 | 5658 | ...backgroundLivenessItems, |
| ... | ... | @@ -5692,6 +5701,7 @@ export default function ChatView(props: ChatViewProps) { |
| 5692 | 5701 | }, [ |
| 5693 | 5702 | activeBranchMismatchKey, |
| 5694 | 5703 | backgroundLivenessBannerItem, |
| 5704 | feedbackBannerItems, | |
| 5695 | 5705 | handleRestoreThreadBranch, |
| 5696 | 5706 | isRestoringThreadBranch, |
| 5697 | 5707 | localCheckoutBranchMismatch, |
| ... | ... | @@ -6276,7 +6286,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6276 | 6286 | return; |
| 6277 | 6287 | } |
| 6278 | 6288 | feedbackUploadsInFlightRef.current.add(routeThreadKey); |
| 6279 | const result = await submitCodexFeedback({ | |
| 6289 | await submitCodexFeedback({ | |
| 6280 | 6290 | submission: { |
| 6281 | 6291 | id: newMessageId(), |
| 6282 | 6292 | command: trimmed, |
| ... | ... | @@ -6286,7 +6296,6 @@ export default function ChatView(props: ChatViewProps) { |
| 6286 | 6296 | promptRef.current = ""; |
| 6287 | 6297 | clearComposerDraftContent(composerDraftTarget); |
| 6288 | 6298 | composerRef.current?.resetCursorState(); |
| 6289 | scrollToEnd(); | |
| 6290 | 6299 | }, |
| 6291 | 6300 | onUpdate: (submission) => { |
| 6292 | 6301 | setFeedbackSubmissionsByThreadKey((current) => { |
| ... | ... | @@ -6311,43 +6320,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6311 | 6320 | }).finally(() => { |
| 6312 | 6321 | feedbackUploadsInFlightRef.current.delete(routeThreadKey); |
| 6313 | 6322 | }); |
| 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 | ||
| 6351 | 6324 | return; |
| 6352 | 6325 | } |
| 6353 | 6326 | if ( |
apps/web/src/components/chat/ComposerFeedback.tsx created+49| ... | ... | @@ -0,0 +1,49 @@ |
| 1 | import { | |
| 2 | codexFeedbackNotice, | |
| 3 | type CodexFeedbackSubmission, | |
| 4 | } from "@t3tools/client-runtime/state/threads"; | |
| 5 | import { MessageSquareIcon } from "lucide-react"; | |
| 6 | ||
| 7 | import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; | |
| 8 | import { Button } from "../ui/button"; | |
| 9 | import { toastManager } from "../ui/toast"; | |
| 10 | import type { ComposerBannerStackItem } from "./ComposerBannerStack"; | |
| 11 | ||
| 12 | export 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 | 1 | import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; |
| 2 | import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; | |
| 3 | 2 | import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react"; |
| 4 | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 5 | 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| ... | ... | @@ -328,61 +327,6 @@ describe("MessagesTimeline", () => { |
| 328 | 327 | }, |
| 329 | 328 | ); |
| 330 | 329 | |
| 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 | 330 | it("renders elapsed time for a completed turn", () => { |
| 387 | 331 | const turnId = TurnId.make("turn-with-fold"); |
| 388 | 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 | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | codexFeedbackMessage, | |
| 7 | codexFeedbackNotice, | |
| 8 | 8 | parseCodexFeedbackCommand, |
| 9 | 9 | submitCodexFeedback, |
| 10 | 10 | type CodexFeedbackSubmission, |
| ... | ... | @@ -40,7 +40,7 @@ describe("submitCodexFeedback", () => { |
| 40 | 40 | createdAt: "2026-08-23T00:00:00.000Z", |
| 41 | 41 | } as const; |
| 42 | 42 | |
| 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 | 44 | let draft: string = submission.command; |
| 45 | 45 | let finishUpload: |
| 46 | 46 | | ((result: ReturnType<typeof AsyncResult.success<{ feedbackId: string }>>) => void) |
| ... | ... | @@ -66,14 +66,10 @@ describe("submitCodexFeedback", () => { |
| 66 | 66 | |
| 67 | 67 | expect(draft).toBe(""); |
| 68 | 68 | 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, | |
| 73 | 72 | }); |
| 74 | expect(codexFeedbackMessage(states[0]!, "assistant").text).toBe( | |
| 75 | "Sending feedback to OpenAI...", | |
| 76 | ); | |
| 77 | 73 | |
| 78 | 74 | draft = "Keep this newer message."; |
| 79 | 75 | finishUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" })); |
| ... | ... | @@ -85,7 +81,7 @@ describe("submitCodexFeedback", () => { |
| 85 | 81 | status: "sent", |
| 86 | 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 | }); |
| 90 | 86 | |
| 91 | 87 | it("records a failed upload without losing its user-facing error", async () => { |
| ... | ... | @@ -105,6 +101,7 @@ describe("submitCodexFeedback", () => { |
| 105 | 101 | status: "failed", |
| 106 | 102 | errorMessage: "Upload rejected.", |
| 107 | 103 | }); |
| 104 | expect(codexFeedbackNotice(states.at(-1)!)?.description).toBe("Upload rejected."); | |
| 108 | 105 | }); |
| 109 | 106 | |
| 110 | 107 | it("marks interruptions without reporting them as upload failures", async () => { |
| ... | ... | @@ -119,6 +116,7 @@ describe("submitCodexFeedback", () => { |
| 119 | 116 | }); |
| 120 | 117 | |
| 121 | 118 | expect(states.at(-1)).toEqual({ ...submission, status: "interrupted" }); |
| 119 | expect(codexFeedbackNotice(states.at(-1)!)).toBeNull(); | |
| 122 | 120 | }); |
| 123 | 121 | |
| 124 | 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 | import { | |
| 2 | MessageId, | |
| 3 | type OrchestrationMessage, | |
| 4 | type ProviderUploadFeedbackResult, | |
| 5 | } from "@t3tools/contracts"; | |
| 1 | import type { MessageId, ProviderUploadFeedbackResult } from "@t3tools/contracts"; | |
| 6 | 2 | |
| 7 | 3 | import { |
| 8 | 4 | isAtomCommandInterrupted, |
| ... | ... | @@ -32,28 +28,20 @@ export function parseCodexFeedbackCommand(text: string): { readonly reason?: str |
| 32 | 28 | return reason ? { reason } : {}; |
| 33 | 29 | } |
| 34 | 30 | |
| 35 | export 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 | }; | |
| 31 | export 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 | } | |
| 57 | 45 | } |
| 58 | 46 | |
| 59 | 47 | export async function submitCodexFeedback<E>(input: { |