| 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,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"; |
| 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 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; | 10 | import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; |
| 8 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; | 11 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; |
| 9 | import type { LegendListRef } from "@legendapp/list/react-native"; | 12 | import 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"; |
| 74 | import { PendingApprovalCard } from "./PendingApprovalCard"; | 77 | import { PendingApprovalCard } from "./PendingApprovalCard"; |
| 78 | import { ComposerFeedback } from "./ComposerFeedback"; | ||
| 75 | import { ComposerUsageLimits } from "./ComposerUsageLimits"; | 79 | import { ComposerUsageLimits } from "./ComposerUsageLimits"; |
| 76 | import { PendingUserInputCard } from "./PendingUserInputCard"; | 80 | import { PendingUserInputCard } from "./PendingUserInputCard"; |
| 77 | import { | 81 | import { |
| ... | @@ -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.View | 864 | <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 @@ |
| 1 | import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; | 1 | import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; | ||
| 4 | 3 | ||
| 5 | import { | 4 | import { |
| 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"; |
| 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 | const singleSelectQuestion = { | 29 | const 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 | ); |
| 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 | 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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import { Alert } from "react-native"; | 3 | import { Alert } from "react-native"; |
| 4 | import * as Cause from "effect/Cause"; | ||
| 5 | 4 | ||
| 6 | import { | 5 | import { |
| 7 | CommandId, | 6 | CommandId, |
| ... | @@ -16,12 +15,10 @@ import { | ... | @@ -16,12 +15,10 @@ import { |
| 16 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 17 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | 16 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 18 | import { | 17 | import { |
| 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"; |
| 24 | import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; | ||
| 25 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; | 22 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; |
| 26 | 23 | ||
| 27 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; | 24 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; |
| ... | @@ -35,7 +32,6 @@ import { | ... | @@ -35,7 +32,6 @@ import { |
| 35 | } from "../lib/composerImages"; | 32 | } from "../lib/composerImages"; |
| 36 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 33 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 37 | import { scopedThreadKey } from "../lib/scopedEntities"; | 34 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 38 | import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; | ||
| 39 | import { buildThreadFeed } from "../lib/threadActivity"; | 35 | import { buildThreadFeed } from "../lib/threadActivity"; |
| 40 | import { appAtomRegistry } from "../state/atom-registry"; | 36 | import { appAtomRegistry } from "../state/atom-registry"; |
| 41 | import { | 37 | import { |
| ... | @@ -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 = selectedThreadKey | 126 | () => (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 && selectedThreadActivities | 143 | 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 | ); |
| 151 | 151 | ||
| 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 | } |
| 346 | 327 | ||
| ... | @@ -573,6 +554,8 @@ export function useThreadComposerState() { | ... | @@ -573,6 +554,8 @@ export function useThreadComposerState() { |
| 573 | ); | 554 | ); |
| 574 | 555 | ||
| 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"; |
| 8 | import { feedbackBannerItem } from "./chat/ComposerFeedback"; | ||
| 8 | import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; | 9 | import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; |
| 9 | import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; | 10 | import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; |
| 10 | import { | 11 | import { |
| ... | @@ -40,7 +41,6 @@ import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; | ... | @@ -40,7 +41,6 @@ import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; |
| 40 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; | 41 | import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 41 | import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; | 42 | import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; |
| 42 | import { | 43 | import { |
| 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 | }); |
| 3029 | 3029 | ||
| 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 @@ | ||
| 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,5 +1,4 @@ |
| 1 | import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; | 1 | import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; |
| 2 | import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; | ||
| 3 | import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react"; | 2 | import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react"; |
| 4 | import { renderToStaticMarkup } from "react-dom/server"; | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 5 | import { create, type ReactTestRenderer } from "react-test-renderer"; | 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| ... | @@ -328,61 +327,6 @@ describe("MessagesTimeline", () => { | ... | @@ -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 | 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"; |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | 5 | ||
| 6 | import { | 6 | import { |
| 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; |
| 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 | 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", () => { |
| 66 | 66 | ||
| 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 | ); | ||
| 77 | 73 | ||
| 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 | }); |
| 90 | 86 | ||
| 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 | }); |
| 109 | 106 | ||
| 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 | }); |
| 120 | 117 | ||
| 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 | }); |
| 123 | 121 | ||
| 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 @@ |
| 1 | import { | 1 | import type { MessageId, ProviderUploadFeedbackResult } from "@t3tools/contracts"; |
| 2 | MessageId, | ||
| 3 | type OrchestrationMessage, | ||
| 4 | type ProviderUploadFeedbackResult, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | 2 | ||
| 7 | import { | 3 | import { |
| 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 | } |
| 34 | 30 | ||
| 35 | export function codexFeedbackMessage( | 31 | export 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.command | 37 | 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": |
| 47 | 43 | 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 | } |
| 58 | 46 | ||
| 59 | export async function submitCodexFeedback<E>(input: { | 47 | export async function submitCodexFeedback<E>(input: { |