@agent-native/core 0.164.15 → 0.164.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/netlify.toml +4 -0
  3. package/corpus/templates/clips/actions/request-transcript.ts +10 -6
  4. package/corpus/templates/content/app/components/editor/BubbleToolbar.tsx +298 -3
  5. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +3 -0
  6. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +18 -4
  7. package/corpus/templates/content/app/i18n-data.ts +160 -0
  8. package/corpus/templates/content/docs/solutions/2026-08-18-colored-text-highlighting-shape.md +693 -0
  9. package/corpus/templates/content/package.json +1 -0
  10. package/corpus/templates/content/shared/nfm.ts +14 -3
  11. package/corpus/templates/factory/AGENTS.md +24 -29
  12. package/corpus/templates/factory/actions/get-slack-feedback-context.ts +8 -28
  13. package/corpus/templates/factory/actions/get-triage-config.ts +1 -0
  14. package/corpus/templates/factory/actions/govern-agent-native-pull-request.ts +67 -59
  15. package/corpus/templates/factory/actions/list-triage-items.ts +6 -1
  16. package/corpus/templates/factory/actions/reconcile-triage-run.ts +7 -1
  17. package/corpus/templates/factory/actions/save-triage-config.ts +27 -7
  18. package/corpus/templates/factory/actions/start-builder-for-item.ts +113 -31
  19. package/corpus/templates/factory/actions/view-screen.ts +12 -0
  20. package/corpus/templates/factory/app/components/factory/FactorySettingsView.tsx +457 -0
  21. package/corpus/templates/factory/app/components/factory/FactoryWorkspaceActions.tsx +53 -0
  22. package/corpus/templates/factory/app/components/layout/Layout.tsx +2 -0
  23. package/corpus/templates/factory/app/components/layout/Sidebar.tsx +3 -1
  24. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +10 -7
  25. package/corpus/templates/factory/app/i18n/en-US.ts +3 -0
  26. package/corpus/templates/factory/app/routes/agents.tsx +22 -1
  27. package/corpus/templates/factory/app/routes/factory-settings.tsx +34 -0
  28. package/corpus/templates/factory/app/routes/factory.tsx +33 -323
  29. package/corpus/templates/factory/server/connectors/slack.ts +62 -0
  30. package/corpus/templates/factory/server/db/schema.ts +1 -0
  31. package/corpus/templates/factory/server/lib/slack-feedback-prompt.ts +30 -0
  32. package/corpus/templates/factory/server/plugins/agent-chat.ts +9 -8
  33. package/corpus/templates/factory/server/plugins/factory-migrations.ts +7 -0
  34. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +43 -25
  35. package/corpus/templates/factory/server/triage/ai-services-git.ts +2 -0
  36. package/corpus/templates/factory/server/triage/pr-monitor.ts +2 -1
  37. package/corpus/templates/factory/server/triage/pr-policy.ts +197 -19
  38. package/corpus/templates/factory/server/triage/review-skill-alignment.ts +127 -0
  39. package/corpus/templates/factory/server/triage/slack-client.ts +79 -6
  40. package/dist/client/MultiTabAssistantChat.js +28 -1
  41. package/dist/client/use-chat-threads.d.ts +1 -0
  42. package/dist/client/use-chat-threads.js +13 -3
  43. package/dist/collab/awareness.d.ts +2 -2
  44. package/dist/notifications/routes.d.ts +3 -3
  45. package/dist/observability/routes.d.ts +5 -5
  46. package/dist/progress/routes.d.ts +1 -1
  47. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  48. package/dist/server/auth.js +2 -2
  49. package/dist/server/realtime-token.d.ts +1 -1
  50. package/dist/server/transcribe-voice.d.ts +1 -1
  51. package/dist/vite/client.js +2 -0
  52. package/package.json +1 -1
@@ -25,10 +25,40 @@ import {
25
25
  import { detectOwnerOwnedArea } from "../server/triage/pr-policy.js";
26
26
  import { createSlackReader } from "../server/triage/slack-client.js";
27
27
 
28
- const replyTextPrefix =
29
- "@builder.io please run /address-feedback in the repo to address this feedback. Read the address-feedback, address-feedback-with-replies, review-latest-feedback, and review-prs skills as relevant, inspect the full thread and linked evidence, and fix the owning boundary. Please send a PR when ready.";
28
+ /** Slack notifies only with `<@USERID>`. Plaintext @handles do not ping anyone. */
29
+ const REPLY_INSTRUCTION =
30
+ "please run /address-feedback in the repo to address this feedback. Read the address-feedback, address-feedback-with-replies, review-latest-feedback, and review-prs skills as relevant, inspect the full thread and linked evidence, and fix the owning boundary. Please send a PR when ready, then have the @agent-native bot post a concise Fixed, In progress, or Clarification needed disposition in this same thread; an 👀 reaction or this handoff alone is not completion.";
31
+ const plaintextBuilderReplyPrefix =
32
+ "@builder.io please run /address-feedback in the repo to address this feedback.";
30
33
  const legacyReplyTextPrefix = "@builderio please fix this in a reply.";
31
34
 
35
+ export function requireBuilderSlackUserId(
36
+ value: string | null | undefined,
37
+ ): string {
38
+ const id = typeof value === "string" ? value.trim() : "";
39
+ if (!/^[UW][A-Z0-9]+$/i.test(id)) {
40
+ throw new Error(
41
+ "Configure a Builder Slack member id in Factory settings before tagging Builder.",
42
+ );
43
+ }
44
+ return id.toUpperCase();
45
+ }
46
+
47
+ export function replyTextPrefixFor(builderSlackUserId: string): string {
48
+ return `<@${requireBuilderSlackUserId(builderSlackUserId)}> ${REPLY_INSTRUCTION}`;
49
+ }
50
+
51
+ function messageHasBuilderHandoff(
52
+ text: string,
53
+ builderSlackUserId: string,
54
+ ): boolean {
55
+ return (
56
+ text.includes(replyTextPrefixFor(builderSlackUserId)) ||
57
+ text.includes(plaintextBuilderReplyPrefix) ||
58
+ text.includes(legacyReplyTextPrefix)
59
+ );
60
+ }
61
+
32
62
  type RelatedFeedbackItem = {
33
63
  id: string;
34
64
  sourceUrl: string | null;
@@ -93,6 +123,7 @@ export function replyTextForItem(
93
123
  sourceUrl: string | null;
94
124
  },
95
125
  relatedItems: RelatedFeedbackItem[] = [],
126
+ builderSlackUserId: string,
96
127
  ): string {
97
128
  const relatedText =
98
129
  relatedItems.length > 0
@@ -105,7 +136,7 @@ export function replyTextForItem(
105
136
  ]
106
137
  : [];
107
138
  return [
108
- replyTextPrefix,
139
+ replyTextPrefixFor(builderSlackUserId),
109
140
  `Factory item: ${item.id}`,
110
141
  item.sourceUrl ? `Source: ${item.sourceUrl}` : "",
111
142
  ...relatedText,
@@ -199,7 +230,7 @@ export async function recordAutomaticBuilderDecision(input: {
199
230
 
200
231
  export default defineAction({
201
232
  description:
202
- "Start the governed clear-bug Builder flow for a Factory item, or record a skip with a reason when clearBug is false. Slack items are kept in-thread by adding 👀 and tagging @builder.io; grouped Slack repeats share one Builder thread. GitHub issues and Sentry errors use the Builder agent run API. Owner-managed Clips, Design, and Content items are always left for their owner.",
233
+ "Start the governed clear-bug Builder flow for a Factory item, or record a skip with a reason when clearBug is false. Slack items stay in-thread: this action adds 👀 and pings Builder with the configured Slack member id; do not post Slack messages or @handles yourself. Grouped Slack repeats share one Builder thread. GitHub issues and Sentry errors use the Builder agent run API. Owner-managed Clips, Design, and Content items are always left for their owner.",
203
234
  schema: z.object({
204
235
  itemId: z.string().min(1),
205
236
  clearBug: z.boolean(),
@@ -464,7 +495,10 @@ export default defineAction({
464
495
  }
465
496
  const config = (
466
497
  await db
467
- .select({ slackWorkspace: triageConfig.slackWorkspace })
498
+ .select({
499
+ slackWorkspace: triageConfig.slackWorkspace,
500
+ builderSlackUserId: triageConfig.builderSlackUserId,
501
+ })
468
502
  .from(triageConfig)
469
503
  .where(
470
504
  and(eq(triageConfig.id, orgId), eq(triageConfig.orgId, orgId)),
@@ -473,67 +507,115 @@ export default defineAction({
473
507
  )[0];
474
508
  const workspace =
475
509
  config?.slackWorkspace === "secondary" ? "secondary" : "primary";
510
+ const builderSlackUserId = requireBuilderSlackUserId(
511
+ config?.builderSlackUserId,
512
+ );
476
513
  const slack = createSlackReader({ ownerEmail: userEmail, orgId });
477
- for (const feedbackItem of [item, ...relatedItems]) {
478
- const feedbackMetadata =
479
- feedbackItem.id === itemId
480
- ? metadata
481
- : parseTriageMetadata(feedbackItem.metadataJson);
482
- if (metadataBoolean(feedbackMetadata, "slackEyesReactedAt")) {
483
- continue;
514
+ const thread = await slack.getCompleteThread(
515
+ workspace,
516
+ item.channelId,
517
+ item.threadTs,
518
+ );
519
+ const completeThreads = new Map([[item.id, thread]]);
520
+ if (thread.hasMore) {
521
+ throw new Error(
522
+ "Slack thread is truncated; refusing to dispatch without complete evidence.",
523
+ );
524
+ }
525
+ for (const related of relatedItems) {
526
+ if (!related.channelId || !related.threadTs) {
527
+ throw new Error(
528
+ "Grouped Slack feedback is missing a channel or thread identity.",
529
+ );
530
+ }
531
+ const relatedThread = await slack.getCompleteThread(
532
+ workspace,
533
+ related.channelId,
534
+ related.threadTs,
535
+ );
536
+ completeThreads.set(related.id, relatedThread);
537
+ if (relatedThread.hasMore) {
538
+ throw new Error(
539
+ "A grouped Slack thread is truncated; refusing to dispatch without complete evidence.",
540
+ );
484
541
  }
485
- const reaction = await slack.addEyesReaction(
542
+ }
543
+ for (const feedbackItem of [item, ...relatedItems]) {
544
+ const reactionState = await slack.getEyesReaction(
486
545
  workspace,
487
546
  feedbackItem.channelId!,
488
547
  feedbackItem.threadTs!,
489
548
  );
549
+ const reaction = reactionState.eyesPresent
550
+ ? { added: false, already_present: true }
551
+ : await slack.addEyesReaction(
552
+ workspace,
553
+ feedbackItem.channelId!,
554
+ feedbackItem.threadTs!,
555
+ );
490
556
  await writeMetadata(feedbackItem.id, orgId, {
491
557
  slackEyesReactedAt: new Date().toISOString(),
492
558
  slackEyesReactionAlreadyPresent: reaction.already_present,
493
559
  });
494
560
  }
495
- const thread = await slack.getThread(
496
- workspace,
497
- item.channelId,
498
- item.threadTs,
499
- 100,
500
- );
501
- const hasCurrentBuilderReply = thread.messages.some((message) =>
502
- message.text.includes(replyTextPrefix),
503
- );
504
- const hasLegacyBuilderReply = thread.messages.some((message) =>
505
- message.text.includes(legacyReplyTextPrefix),
561
+ const hasBuilderReply = thread.messages.some((message) =>
562
+ messageHasBuilderHandoff(message.text, builderSlackUserId),
506
563
  );
507
564
  const hasRelatedClusterDetails = thread.messages.some((message) =>
508
565
  message.text.includes(
509
566
  "Related feedback in the same issue cluster - inspect and fix all of these too:",
510
567
  ),
511
568
  );
512
- if (
513
- thread.has_more &&
514
- !hasCurrentBuilderReply &&
515
- !hasLegacyBuilderReply
516
- ) {
569
+ if (thread.hasMore && !hasBuilderReply) {
517
570
  throw new Error(
518
571
  "Slack thread is truncated; refusing to risk a duplicate Builder handoff.",
519
572
  );
520
573
  }
521
574
  if (
522
575
  !metadataBoolean(metadata, "slackBuilderReplyAt") &&
523
- ((!hasCurrentBuilderReply && !hasLegacyBuilderReply) ||
576
+ (!hasBuilderReply ||
524
577
  (relatedItems.length > 0 && !hasRelatedClusterDetails))
525
578
  ) {
526
579
  const posted = await slack.postThreadReply(
527
580
  workspace,
528
581
  item.channelId,
529
582
  item.threadTs,
530
- replyTextForItem(item, relatedItems),
583
+ replyTextForItem(item, relatedItems, builderSlackUserId),
531
584
  );
532
585
  await writeMetadata(itemId, orgId, {
533
586
  slackBuilderReplyAt: new Date().toISOString(),
534
587
  slackBuilderReplyTs: posted.ts,
535
588
  });
536
589
  }
590
+ const agentNative = await slack.getAgentNativeIdentity(workspace);
591
+ for (const feedbackItem of [item, ...relatedItems]) {
592
+ const feedbackThread = completeThreads.get(feedbackItem.id);
593
+ if (!feedbackThread) {
594
+ throw new Error(
595
+ `Slack thread evidence is missing for Factory item ${feedbackItem.id}.`,
596
+ );
597
+ }
598
+ const hasAgentNativeDisposition = feedbackThread.messages.some(
599
+ (message) =>
600
+ (message.user === agentNative.userId ||
601
+ message.username?.trim().toLowerCase() === "agent-native") &&
602
+ /^(Fixed|In progress|Clarification needed):/i.test(
603
+ message.text.trim(),
604
+ ),
605
+ );
606
+ if (hasAgentNativeDisposition) continue;
607
+ const disposition = await slack.postThreadReply(
608
+ workspace,
609
+ feedbackItem.channelId!,
610
+ feedbackItem.threadTs!,
611
+ `Thanks - Builder owns this feedback cluster and will follow up after verification. In progress: ${reason}`,
612
+ );
613
+ await writeMetadata(feedbackItem.id, orgId, {
614
+ slackDispositionAt: new Date().toISOString(),
615
+ slackDispositionTs: disposition.ts,
616
+ slackDisposition: "In progress",
617
+ });
618
+ }
537
619
  const clusterItemIds = [itemId, ...relatedItems.map(({ id }) => id)];
538
620
  const clusterMetadata = {
539
621
  feedbackClusterRepresentativeId: itemId,
@@ -41,6 +41,18 @@ export default defineAction({
41
41
  const screen: Record<string, unknown> = {};
42
42
  if (navigation) screen.navigation = navigation;
43
43
 
44
+ if (
45
+ navigation &&
46
+ typeof navigation === "object" &&
47
+ "view" in navigation &&
48
+ navigation.view === "factory-settings"
49
+ ) {
50
+ screen.factorySettings = {
51
+ scope: "workspace",
52
+ path: "/factory-settings",
53
+ };
54
+ }
55
+
44
56
  if (
45
57
  navigation &&
46
58
  typeof navigation === "object" &&
@@ -0,0 +1,457 @@
1
+ import {
2
+ useActionMutation,
3
+ useActionQuery,
4
+ } from "@agent-native/core/client/hooks";
5
+ import { useT } from "@agent-native/core/client/i18n";
6
+ import { buildSettingsRoute } from "@agent-native/core/client/navigation";
7
+ import { SettingsGroup, SettingsRow } from "@agent-native/core/client/settings";
8
+ import { ActionQueryError } from "@agent-native/dispatch/components";
9
+ import { IconLoader2 } from "@tabler/icons-react";
10
+ import { useEffect, useState } from "react";
11
+ import { Link } from "react-router";
12
+ import { toast } from "sonner";
13
+
14
+ import { Button } from "@/components/ui/button";
15
+ import { Input } from "@/components/ui/input";
16
+ import { Switch } from "@/components/ui/switch";
17
+
18
+ type TriageConfig = {
19
+ slackWorkspace?: "primary" | "secondary";
20
+ slackChannelId?: string | null;
21
+ slackChannelName?: string | null;
22
+ builderSlackUserId?: string | null;
23
+ pollingEnabled?: boolean;
24
+ githubPollingEnabled?: boolean;
25
+ sentryPollingEnabled?: boolean;
26
+ sentryOrgSlug?: string | null;
27
+ sentryProjectSlug?: string | null;
28
+ sentryEnvironment?: string | null;
29
+ repository?: string | null;
30
+ automationFailureAlertsEnabled?: boolean;
31
+ automationFailureAlertEmail?: string | null;
32
+ emailReadiness?: {
33
+ status: "ready" | "not-configured" | "misconfigured" | "unavailable";
34
+ provider: string;
35
+ };
36
+ };
37
+
38
+ type FactoryAutomationHealth = {
39
+ status: "healthy" | "stale" | "error" | "no-data";
40
+ lastCheckedAt?: number | null;
41
+ lastDispatchedAt?: number | null;
42
+ lastError?: string | null;
43
+ runtime?: string | null;
44
+ };
45
+
46
+ function formatAutomationDate(value: string | number | null | undefined) {
47
+ if (!value) return "-";
48
+ const date = new Date(value);
49
+ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
50
+ }
51
+
52
+ export function FactorySettingsView() {
53
+ const t = useT();
54
+ const [workspace, setWorkspace] = useState<"primary" | "secondary">(
55
+ "primary",
56
+ );
57
+ const [channelId, setChannelId] = useState("");
58
+ const [channelName, setChannelName] = useState("");
59
+ const [builderSlackUserId, setBuilderSlackUserId] = useState("");
60
+ const [repository, setRepository] = useState("");
61
+ const [polling, setPolling] = useState(false);
62
+ const [githubPolling, setGithubPolling] = useState(false);
63
+ const [sentryPolling, setSentryPolling] = useState(false);
64
+ const [sentryOrgSlug, setSentryOrgSlug] = useState("");
65
+ const [sentryProjectSlug, setSentryProjectSlug] = useState("");
66
+ const [sentryEnvironment, setSentryEnvironment] = useState("");
67
+ const [automationFailureAlertsEnabled, setAutomationFailureAlertsEnabled] =
68
+ useState(true);
69
+ const [automationFailureAlertEmail, setAutomationFailureAlertEmail] =
70
+ useState("");
71
+ const query = useActionQuery("get-triage-config", {});
72
+ const schedulerHealthQuery = useActionQuery<FactoryAutomationHealth>(
73
+ "get-factory-automation-health",
74
+ {},
75
+ { refetchInterval: 60_000 },
76
+ );
77
+ const mutation = useActionMutation("save-triage-config");
78
+
79
+ useEffect(() => {
80
+ const data = query.data as TriageConfig | undefined;
81
+ if (!data) return;
82
+ setWorkspace(data.slackWorkspace ?? "primary");
83
+ setChannelId(data.slackChannelId ?? "");
84
+ setChannelName(data.slackChannelName ?? "");
85
+ setBuilderSlackUserId(data.builderSlackUserId ?? "");
86
+ setRepository(data.repository ?? "");
87
+ setPolling(data.pollingEnabled ?? false);
88
+ setGithubPolling(data.githubPollingEnabled ?? false);
89
+ setSentryPolling(data.sentryPollingEnabled ?? false);
90
+ setSentryOrgSlug(data.sentryOrgSlug ?? "");
91
+ setSentryProjectSlug(data.sentryProjectSlug ?? "");
92
+ setSentryEnvironment(data.sentryEnvironment ?? "");
93
+ setAutomationFailureAlertsEnabled(
94
+ data.automationFailureAlertsEnabled ?? true,
95
+ );
96
+ setAutomationFailureAlertEmail(data.automationFailureAlertEmail ?? "");
97
+ }, [query.data]);
98
+
99
+ const configLoaded = Boolean(query.data) && !query.isError;
100
+
101
+ const saveSettings = async () => {
102
+ if (!configLoaded) {
103
+ toast.error(t("triage.settingsError"));
104
+ return;
105
+ }
106
+ try {
107
+ await mutation.mutateAsync({
108
+ slackWorkspace: workspace,
109
+ slackChannelId: channelId,
110
+ slackChannelName: channelName,
111
+ builderSlackUserId,
112
+ repository,
113
+ pollingEnabled: polling,
114
+ githubPollingEnabled: githubPolling,
115
+ sentryPollingEnabled: sentryPolling,
116
+ sentryOrgSlug,
117
+ sentryProjectSlug,
118
+ sentryEnvironment,
119
+ automationFailureAlertsEnabled,
120
+ automationFailureAlertEmail: automationFailureAlertEmail.trim(),
121
+ });
122
+ toast.success(t("triage.settingsSaved"));
123
+ } catch (error) {
124
+ toast.error(
125
+ error instanceof Error ? error.message : t("triage.settingsError"),
126
+ );
127
+ }
128
+ };
129
+
130
+ if (query.isLoading) {
131
+ return (
132
+ <div className="mx-auto w-full max-w-3xl space-y-6 p-4 lg:p-6">
133
+ <FactorySettingsSkeleton t={t} />
134
+ </div>
135
+ );
136
+ }
137
+
138
+ if (!configLoaded) {
139
+ return (
140
+ <div className="mx-auto w-full max-w-3xl p-4 lg:p-6">
141
+ <ActionQueryError
142
+ error={query.error}
143
+ onRetry={() => void query.refetch()}
144
+ />
145
+ </div>
146
+ );
147
+ }
148
+
149
+ const fieldControlClass = "h-9 w-full sm:w-64";
150
+
151
+ return (
152
+ <div className="mx-auto w-full max-w-3xl space-y-6 p-4 lg:p-6">
153
+ <SettingsGroup variant="soft">
154
+ <SettingsRow
155
+ label={t("factoryRoute.workspaceIntegrations")}
156
+ description={t("settings.workspaceDescription")}
157
+ control={
158
+ <Button asChild type="button" variant="outline">
159
+ <Link to={buildSettingsRoute("integrations")}>Manage</Link>
160
+ </Button>
161
+ }
162
+ />
163
+ <SettingsRow
164
+ label={t("factoryRoute.agentAccess")}
165
+ description={t("settings.agentDescription")}
166
+ control={
167
+ <Button asChild type="button" variant="outline">
168
+ <Link to={buildSettingsRoute("agent")}>Manage</Link>
169
+ </Button>
170
+ }
171
+ />
172
+ </SettingsGroup>
173
+
174
+ <SettingsGroup variant="soft">
175
+ <SettingsRow
176
+ label={t("triage.slackWorkspace")}
177
+ control={
178
+ <select
179
+ aria-label={t("triage.slackWorkspace")}
180
+ value={workspace}
181
+ onChange={(event) =>
182
+ setWorkspace(event.target.value as "primary" | "secondary")
183
+ }
184
+ className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm sm:w-64"
185
+ >
186
+ <option value="primary">primary</option>
187
+ <option value="secondary">secondary</option>
188
+ </select>
189
+ }
190
+ />
191
+ <SettingsRow
192
+ label={t("triage.slackChannelId")}
193
+ control={
194
+ <Input
195
+ aria-label={t("triage.slackChannelId")}
196
+ value={channelId}
197
+ onChange={(event) => setChannelId(event.target.value)}
198
+ placeholder={t("triage.slackChannelPlaceholder")}
199
+ className={fieldControlClass}
200
+ />
201
+ }
202
+ />
203
+ <SettingsRow
204
+ label={t("triage.slackChannelName")}
205
+ control={
206
+ <Input
207
+ aria-label={t("triage.slackChannelName")}
208
+ value={channelName}
209
+ onChange={(event) => setChannelName(event.target.value)}
210
+ placeholder={t("triage.slackChannelNamePlaceholder")}
211
+ className={fieldControlClass}
212
+ />
213
+ }
214
+ />
215
+ <SettingsRow
216
+ label={t("triage.builderSlackUserId")}
217
+ control={
218
+ <Input
219
+ aria-label={t("triage.builderSlackUserId")}
220
+ value={builderSlackUserId}
221
+ onChange={(event) => setBuilderSlackUserId(event.target.value)}
222
+ placeholder={t("triage.builderSlackUserIdPlaceholder")}
223
+ className={fieldControlClass}
224
+ />
225
+ }
226
+ />
227
+ <SettingsRow
228
+ label={t("triage.repository")}
229
+ control={
230
+ <Input
231
+ aria-label={t("triage.repository")}
232
+ value={repository}
233
+ onChange={(event) => setRepository(event.target.value)}
234
+ placeholder={t("triage.repositoryPlaceholder")}
235
+ className={fieldControlClass}
236
+ />
237
+ }
238
+ />
239
+ <SettingsRow
240
+ label={t("triage.enablePolling")}
241
+ control={
242
+ <Switch
243
+ aria-label={t("triage.enablePolling")}
244
+ checked={polling}
245
+ onCheckedChange={(checked) => setPolling(checked === true)}
246
+ />
247
+ }
248
+ />
249
+ <SettingsRow
250
+ label={t("triage.enableGithubPolling")}
251
+ control={
252
+ <Switch
253
+ aria-label={t("triage.enableGithubPolling")}
254
+ checked={githubPolling}
255
+ onCheckedChange={(checked) => setGithubPolling(checked === true)}
256
+ />
257
+ }
258
+ />
259
+ <SettingsRow
260
+ label={t("triage.sentryOrgSlug")}
261
+ control={
262
+ <Input
263
+ aria-label={t("triage.sentryOrgSlug")}
264
+ value={sentryOrgSlug}
265
+ onChange={(event) => setSentryOrgSlug(event.target.value)}
266
+ placeholder={t("triage.sentryOrgPlaceholder")}
267
+ className={fieldControlClass}
268
+ />
269
+ }
270
+ />
271
+ <SettingsRow
272
+ label={t("triage.sentryProjectSlug")}
273
+ control={
274
+ <Input
275
+ aria-label={t("triage.sentryProjectSlug")}
276
+ value={sentryProjectSlug}
277
+ onChange={(event) => setSentryProjectSlug(event.target.value)}
278
+ placeholder={t("triage.sentryProjectPlaceholder")}
279
+ className={fieldControlClass}
280
+ />
281
+ }
282
+ />
283
+ <SettingsRow
284
+ label={t("triage.sentryEnvironment")}
285
+ control={
286
+ <Input
287
+ aria-label={t("triage.sentryEnvironment")}
288
+ value={sentryEnvironment}
289
+ onChange={(event) => setSentryEnvironment(event.target.value)}
290
+ placeholder={t("triage.sentryEnvironmentPlaceholder")}
291
+ className={fieldControlClass}
292
+ />
293
+ }
294
+ />
295
+ <SettingsRow
296
+ label={t("triage.enableSentryPolling")}
297
+ control={
298
+ <Switch
299
+ aria-label={t("triage.enableSentryPolling")}
300
+ checked={sentryPolling}
301
+ onCheckedChange={(checked) => setSentryPolling(checked === true)}
302
+ />
303
+ }
304
+ />
305
+ </SettingsGroup>
306
+
307
+ <SettingsGroup variant="soft">
308
+ <SettingsRow
309
+ label={t("factoryRoute.automationFailureAlertsTitle")}
310
+ description={t("factoryRoute.automationFailureAlertsDescription")}
311
+ control={
312
+ <Switch
313
+ aria-label={t("factoryRoute.automationFailureAlertsEnabled")}
314
+ checked={automationFailureAlertsEnabled}
315
+ onCheckedChange={(checked) =>
316
+ setAutomationFailureAlertsEnabled(checked === true)
317
+ }
318
+ />
319
+ }
320
+ />
321
+ <SettingsRow
322
+ label={t("factoryRoute.automationFailureAlertEmail")}
323
+ description={t("factoryRoute.automationFailureAlertEmailPlaceholder")}
324
+ control={
325
+ <Input
326
+ aria-label={t("factoryRoute.automationFailureAlertEmail")}
327
+ type="email"
328
+ value={automationFailureAlertEmail}
329
+ onChange={(event) =>
330
+ setAutomationFailureAlertEmail(event.target.value)
331
+ }
332
+ placeholder={t(
333
+ "factoryRoute.automationFailureAlertEmailPlaceholder",
334
+ )}
335
+ className={fieldControlClass}
336
+ />
337
+ }
338
+ />
339
+ <SettingsRow
340
+ label={t("factoryRoute.automationFailureEmailReadiness")}
341
+ description={t("factoryRoute.automationEmailReadinessHint")}
342
+ control={
343
+ <span className="rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
344
+ {(query.data as TriageConfig | undefined)?.emailReadiness
345
+ ?.status ?? "unknown"}
346
+ </span>
347
+ }
348
+ />
349
+ </SettingsGroup>
350
+
351
+ <div className="flex flex-wrap items-center justify-end gap-3">
352
+ <Button
353
+ onClick={() => void saveSettings()}
354
+ disabled={mutation.isPending || !configLoaded}
355
+ >
356
+ {mutation.isPending && <IconLoader2 className="animate-spin" />}
357
+ {t("triage.saveSettings")}
358
+ </Button>
359
+ </div>
360
+
361
+ <SchedulerHealthStatus
362
+ health={schedulerHealthQuery.data}
363
+ isError={schedulerHealthQuery.isError}
364
+ error={schedulerHealthQuery.error}
365
+ t={t}
366
+ />
367
+ </div>
368
+ );
369
+ }
370
+
371
+ function FactorySettingsSkeleton({ t }: { t: ReturnType<typeof useT> }) {
372
+ return (
373
+ <div className="space-y-6" aria-label={t("triage.loading")}>
374
+ {[2, 11, 3].map((rowCount, index) => (
375
+ <div key={index} className="grid gap-2">
376
+ <div className="grid gap-2">
377
+ {Array.from({ length: rowCount }).map((_, rowIndex) => (
378
+ <div
379
+ key={rowIndex}
380
+ className="flex min-h-20 items-center justify-between gap-4 rounded-xl bg-card px-5 py-4 shadow-sm sm:px-6"
381
+ >
382
+ <div className="h-3.5 w-36 animate-pulse rounded bg-muted" />
383
+ <div className="h-9 w-24 animate-pulse rounded bg-muted" />
384
+ </div>
385
+ ))}
386
+ </div>
387
+ </div>
388
+ ))}
389
+ </div>
390
+ );
391
+ }
392
+
393
+ function SchedulerHealthStatus({
394
+ health,
395
+ isError,
396
+ error,
397
+ t,
398
+ }: {
399
+ health?: FactoryAutomationHealth;
400
+ isError: boolean;
401
+ error: unknown;
402
+ t: ReturnType<typeof useT>;
403
+ }) {
404
+ const healthLabel = isError
405
+ ? t("factoryRoute.automationHealthError")
406
+ : health
407
+ ? {
408
+ healthy: t("factoryRoute.automationHealthHealthy"),
409
+ stale: t("factoryRoute.automationHealthStale"),
410
+ error: t("factoryRoute.automationHealthError"),
411
+ "no-data": t("factoryRoute.automationHealthNoData"),
412
+ }[health.status]
413
+ : t("factoryRoute.automationHealthNoData");
414
+ const hasNoHeartbeat =
415
+ !isError &&
416
+ (!health || health.status === "no-data") &&
417
+ !health?.lastCheckedAt;
418
+ const healthDescription = isError
419
+ ? `${t("factoryRoute.automationDiagnosticsLoadError")} ${error instanceof Error ? error.message : String(error)}`
420
+ : health?.lastError
421
+ ? `${t("factoryRoute.automationHealthErrorDetail")}: ${health.lastError}`
422
+ : health?.status === "stale"
423
+ ? t("factoryRoute.automationHealthStaleHint")
424
+ : hasNoHeartbeat
425
+ ? t("factoryRoute.automationHealthNoDataHint")
426
+ : undefined;
427
+
428
+ return (
429
+ <SettingsGroup variant="soft">
430
+ <SettingsRow
431
+ label={t("factoryRoute.automationHealthTitle")}
432
+ description={
433
+ healthDescription ?? t("factoryRoute.automationHealthDescription")
434
+ }
435
+ control={
436
+ <div className="flex flex-wrap items-center justify-end gap-x-4 gap-y-1">
437
+ <span className="rounded-full bg-muted px-2.5 py-1 text-xs font-medium">
438
+ {healthLabel}
439
+ </span>
440
+ {health?.lastCheckedAt && (
441
+ <span className="text-xs text-muted-foreground">
442
+ {t("factoryRoute.automationLastCheck")}:{" "}
443
+ {formatAutomationDate(health.lastCheckedAt)}
444
+ </span>
445
+ )}
446
+ {health?.lastDispatchedAt && (
447
+ <span className="text-xs text-muted-foreground">
448
+ {t("factoryRoute.automationLastDispatch")}:{" "}
449
+ {formatAutomationDate(health.lastDispatchedAt)}
450
+ </span>
451
+ )}
452
+ </div>
453
+ }
454
+ />
455
+ </SettingsGroup>
456
+ );
457
+ }