@agent-native/core 0.157.5 → 0.157.7

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 (109) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/agent-native.config.ts +5 -0
  3. package/corpus/templates/analytics/netlify.toml +1 -0
  4. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +23 -5
  5. package/corpus/templates/analytics/server/plugins/agent-chat.ts +4 -0
  6. package/corpus/templates/assets/netlify.toml +3 -0
  7. package/corpus/templates/brain/netlify.toml +3 -0
  8. package/corpus/templates/calendar/agent-native.config.ts +5 -0
  9. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +4 -1
  10. package/corpus/templates/calendar/netlify.toml +3 -0
  11. package/corpus/templates/chat/agent-native.config.ts +5 -0
  12. package/corpus/templates/chat/netlify.toml +3 -0
  13. package/corpus/templates/clips/actions/create-meeting.ts +19 -1
  14. package/corpus/templates/clips/actions/lib/meeting-content.ts +28 -0
  15. package/corpus/templates/clips/actions/list-meetings.ts +33 -11
  16. package/corpus/templates/clips/app/components/meetings/bullet-link.tsx +48 -25
  17. package/corpus/templates/clips/app/components/meetings/canvas-editor.tsx +7 -6
  18. package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +388 -178
  19. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +7 -13
  20. package/corpus/templates/clips/app/components/transcript/transcript-segment-row.tsx +98 -0
  21. package/corpus/templates/clips/app/i18n/en-US.ts +5 -0
  22. package/corpus/templates/clips/app/lib/meeting-history-query.ts +11 -0
  23. package/corpus/templates/clips/app/lib/public-meeting.ts +4 -0
  24. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +49 -31
  25. package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +47 -21
  26. package/corpus/templates/clips/app/routes/share.meeting.$meetingId.tsx +15 -1
  27. package/corpus/templates/clips/netlify.toml +8 -4
  28. package/corpus/templates/clips/server/routes/api/public-meeting.get.ts +10 -0
  29. package/corpus/templates/clips/shared/transcript-segments.ts +20 -2
  30. package/corpus/templates/content/netlify.toml +3 -0
  31. package/corpus/templates/design/app/i18n-data.ts +2 -0
  32. package/corpus/templates/design/netlify.toml +3 -0
  33. package/corpus/templates/dispatch/netlify.toml +3 -0
  34. package/corpus/templates/factory/netlify.toml +3 -0
  35. package/corpus/templates/forms/netlify.toml +3 -0
  36. package/corpus/templates/macros/netlify.toml +3 -0
  37. package/corpus/templates/mail/agent-native.config.ts +5 -0
  38. package/corpus/templates/mail/netlify.toml +3 -0
  39. package/corpus/templates/plan/netlify.toml +3 -0
  40. package/corpus/templates/slides/netlify.toml +3 -0
  41. package/dist/agent/harness/acp-adapter.js +17 -2
  42. package/dist/agent/harness/ai-sdk-adapter.js +14 -3
  43. package/dist/agent/harness/background.js +10 -5
  44. package/dist/agent/harness/hosted.d.ts +20 -0
  45. package/dist/agent/harness/hosted.js +100 -0
  46. package/dist/agent/harness/runner.js +100 -77
  47. package/dist/agent/observational-memory/observer.js +12 -0
  48. package/dist/agent/production-agent.d.ts +3 -0
  49. package/dist/agent/production-agent.js +74 -4
  50. package/dist/agent/thread-data-builder.d.ts +12 -0
  51. package/dist/agent/thread-data-builder.js +23 -0
  52. package/dist/agent/types.d.ts +6 -0
  53. package/dist/client/AgentPanel.d.ts +5 -1
  54. package/dist/client/AgentPanel.js +113 -17
  55. package/dist/client/AgentTaskCard.d.ts +2 -2
  56. package/dist/client/AgentTaskCard.js +8 -22
  57. package/dist/client/AssistantChat.d.ts +8 -0
  58. package/dist/client/AssistantChat.js +106 -59
  59. package/dist/client/MultiTabAssistantChat.js +50 -6
  60. package/dist/client/NewWorkspaceAppFlow.js +7 -1
  61. package/dist/client/agent-chat-adapter.d.ts +6 -0
  62. package/dist/client/agent-chat-adapter.js +36 -8
  63. package/dist/client/app-chat-sidebar.d.ts +5 -0
  64. package/dist/client/app-chat-sidebar.js +53 -9
  65. package/dist/client/chat/composer-draft.d.ts +4 -0
  66. package/dist/client/chat/composer-draft.js +63 -0
  67. package/dist/client/chat-first/apps-rail.js +10 -1
  68. package/dist/client/chat-first/primary-nav.js +10 -5
  69. package/dist/client/frame-protocol.d.ts +1 -0
  70. package/dist/client/hooks/index.d.ts +1 -1
  71. package/dist/client/hooks/index.js +1 -1
  72. package/dist/client/i18n.js +5 -2
  73. package/dist/client/progress/RunsTray.js +129 -16
  74. package/dist/client/settings/SettingsRow.js +1 -1
  75. package/dist/client/use-chat-threads.js +11 -6
  76. package/dist/collab/routes.d.ts +1 -1
  77. package/dist/collab/struct-routes.d.ts +1 -1
  78. package/dist/config.d.ts +11 -0
  79. package/dist/config.js +52 -0
  80. package/dist/deploy/build.js +28 -11
  81. package/dist/extensions/actions.js +22 -1
  82. package/dist/hosted-harness/actions/get-hosted-harness-config.d.ts +2 -0
  83. package/dist/hosted-harness/actions/get-hosted-harness-config.js +13 -0
  84. package/dist/hosted-harness/actions/set-hosted-harness-enabled.d.ts +4 -0
  85. package/dist/hosted-harness/actions/set-hosted-harness-enabled.js +32 -0
  86. package/dist/localization/default-messages.d.ts +4 -1
  87. package/dist/localization/default-messages.js +5 -2
  88. package/dist/observability/routes.d.ts +5 -5
  89. package/dist/provider-api/actions/custom-provider-registration.d.ts +6 -6
  90. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  91. package/dist/resources/handlers.d.ts +1 -1
  92. package/dist/secrets/routes.d.ts +9 -9
  93. package/dist/server/action-discovery.js +12 -0
  94. package/dist/server/agent-chat-plugin.js +24 -4
  95. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  96. package/dist/server/agent-teams.d.ts +20 -12
  97. package/dist/server/agent-teams.js +69 -35
  98. package/dist/server/hosted-harness-policy.d.ts +17 -0
  99. package/dist/server/hosted-harness-policy.js +38 -0
  100. package/dist/server/realtime-token.d.ts +1 -1
  101. package/dist/server/request-context.d.ts +4 -0
  102. package/dist/server/transcribe-voice.d.ts +1 -1
  103. package/dist/styles/agent-native.css +39 -0
  104. package/dist/templates/chat/agent-native.config.ts +5 -0
  105. package/dist/templates/chat/netlify.toml +3 -0
  106. package/dist/vite/action-types-plugin.js +8 -0
  107. package/package.json +4 -2
  108. package/src/templates/chat/agent-native.config.ts +5 -0
  109. package/src/templates/chat/netlify.toml +3 -0
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8249
34
+ - template files: 8256
@@ -0,0 +1,5 @@
1
+ import { defineAgentNativeConfig } from "@agent-native/core/config";
2
+
3
+ export default defineAgentNativeConfig({
4
+ harness: true,
5
+ });
@@ -19,6 +19,7 @@ AGENT_PROD_CODE_EXECUTION = "sandboxed"
19
19
  AGENT_CHAT_DURABLE_BACKGROUND = "true"
20
20
 
21
21
  [context.production.environment]
22
+ AGENT_NATIVE_HOSTED_HARNESS = "true"
22
23
  # Analytics owns its schema at release time (`migrate:production` above), so
23
24
  # production request functions skip migrations. Preview functions still run
24
25
  # request-path migrations because previews do not run the production release
@@ -651,9 +651,13 @@ function rewriteSqlFunctionCalls(
651
651
  return match ? result : result + sql.slice(cursor);
652
652
  }
653
653
 
654
- function coerceEventDateComparisonOperands(sql: string): string {
655
- const comparisonRe =
656
- /\b(?:[A-Za-z_][A-Za-z0-9_]*\.)?event_date\s*(?:<=|>=|<>|=|<|>)\s*/gi;
654
+ function coerceDateComparisonOperands(sql: string): string {
655
+ const dateField = "(?:event_date|cohort_date)";
656
+ const qualifiedDateField = `(?:[A-Za-z_][A-Za-z0-9_]*\\.)?${dateField}`;
657
+ const comparisonRe = new RegExp(
658
+ `\\b${qualifiedDateField}\\s*(?:<=|>=|<>|=|<|>)\\s*`,
659
+ "gi",
660
+ );
657
661
  let cursor = 0;
658
662
  let result = "";
659
663
  let match = comparisonRe.exec(sql);
@@ -685,7 +689,10 @@ function coerceEventDateComparisonOperands(sql: string): string {
685
689
  }
686
690
  result += sql.slice(cursor);
687
691
  return result.replace(
688
- /(\b(?:[A-Za-z_][A-Za-z0-9_]*\.)?event_date\s*(?:<=|>=|<>|=|<|>)\s*)'(\d{4}-\d{2}-\d{2})'/gi,
692
+ new RegExp(
693
+ `(\\b${qualifiedDateField}\\s*(?:<=|>=|<>|=|<|>)\\s*)'(\\d{4}-\\d{2}-\\d{2})'`,
694
+ "gi",
695
+ ),
689
696
  "$1DATE '$2'",
690
697
  );
691
698
  }
@@ -813,6 +820,17 @@ function translatePostgresJsonOperators(sql: string): string {
813
820
 
814
821
  function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
815
822
  let translated = translatePostgresJsonOperators(sql);
823
+ translated = rewriteSqlFunctionCalls(translated, "coalesce", (args) => {
824
+ const uniqueArgs: string[] = [];
825
+ const seen = new Set<string>();
826
+ for (const arg of args) {
827
+ const normalized = arg.trim();
828
+ if (seen.has(normalized)) continue;
829
+ seen.add(normalized);
830
+ uniqueArgs.push(arg);
831
+ }
832
+ return `COALESCE(${uniqueArgs.join(", ")})`;
833
+ });
816
834
  translated = translated.replace(
817
835
  /\bINTERVAL\s*'(\d+)\s+(day|days|week|weeks|month|months)'/gi,
818
836
  (_match, amount: string, unit: string) =>
@@ -975,7 +993,7 @@ export function renderFirstPartyAnalyticsBigQuerySql(
975
993
  translateFirstPartyAnalyticsBigQuerySql(normalizedScopeSql);
976
994
  const bound = bindSqlArguments(translated, args);
977
995
  return addPartitionPrunedEventDeduplication(
978
- coerceEventDateComparisonOperands(qualifyQuerySources(bound, table)),
996
+ coerceDateComparisonOperands(qualifyQuerySources(bound, table)),
979
997
  table,
980
998
  );
981
999
  }
@@ -61,6 +61,10 @@ function hasSuccessfulDashboardSave(
61
61
  "update-dashboard",
62
62
  "mutate-dashboard",
63
63
  "compose-dashboard",
64
+ // An extension edit is the whole job when the dashboard panel IS the
65
+ // extension. Leaving it out meant a turn that saved exactly what the user
66
+ // asked for still had to prove itself with a data query.
67
+ "update-extension",
64
68
  ]);
65
69
  return (toolResults ?? []).some((result) => {
66
70
  if (result.isError) return false;
@@ -11,6 +11,9 @@ NITRO_PRESET = "netlify"
11
11
  NPM_CONFIG_PRODUCTION = "false"
12
12
  AGENT_CHAT_DURABLE_BACKGROUND = "true"
13
13
 
14
+ [context.production.environment]
15
+ AGENT_NATIVE_HOSTED_HARNESS = "true"
16
+
14
17
  # Emit the Netlify background function used by Assets' durable agent-chat opt-in.
15
18
  # The normal server timeout still protects non-agent image/video HTTP handlers.
16
19
  [functions."*"]
@@ -10,6 +10,9 @@ GTM_CONTAINER_ID = "GTM-N3WSTXZ"
10
10
  NITRO_PRESET = "netlify"
11
11
  NPM_CONFIG_PRODUCTION = "false"
12
12
 
13
+ [context.production.environment]
14
+ AGENT_NATIVE_HOSTED_HARNESS = "true"
15
+
13
16
  # A2A delegation calls run a full agent loop (LLM + tool calls + DB queries).
14
17
  # The framework does not impose a default agent-run timeout; if a template sets
15
18
  # AGENT_RUN_SOFT_TIMEOUT_MS, keep it below this function timeout so the hidden
@@ -0,0 +1,5 @@
1
+ import { defineAgentNativeConfig } from "@agent-native/core/config";
2
+
3
+ export default defineAgentNativeConfig({
4
+ harness: true,
5
+ });
@@ -385,7 +385,10 @@ export function AppLayout({ children }: AppLayoutProps) {
385
385
  open={shortcutsHelpOpen}
386
386
  onClose={() => setShortcutsHelpOpen(false)}
387
387
  />
388
- <div className="agent-layout-shell flex h-screen overflow-hidden bg-background">
388
+ <div
389
+ className="agent-layout-shell flex h-screen overflow-hidden bg-background"
390
+ data-agent-native-shell-variant="custom"
391
+ >
389
392
  <Sidebar
390
393
  open={sidebarOpen}
391
394
  onClose={() => setSidebarOpen(false)}
@@ -10,6 +10,9 @@ GTM_CONTAINER_ID = "GTM-N3WSTXZ"
10
10
  NITRO_PRESET = "netlify"
11
11
  NPM_CONFIG_PRODUCTION = "false"
12
12
 
13
+ [context.production.environment]
14
+ AGENT_NATIVE_HOSTED_HARNESS = "true"
15
+
13
16
  # A2A delegation calls run a full agent loop (LLM + tool calls + DB queries).
14
17
  # The framework does not impose a default agent-run timeout; if a template sets
15
18
  # AGENT_RUN_SOFT_TIMEOUT_MS, keep it below this function timeout so the hidden
@@ -0,0 +1,5 @@
1
+ import { defineAgentNativeConfig } from "@agent-native/core/config";
2
+
3
+ export default defineAgentNativeConfig({
4
+ harness: true,
5
+ });
@@ -9,3 +9,6 @@ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
9
9
  GTM_CONTAINER_ID = "GTM-N3WSTXZ"
10
10
  NITRO_PRESET = "netlify"
11
11
  NPM_CONFIG_PRODUCTION = "false"
12
+
13
+ [context.production.environment]
14
+ AGENT_NATIVE_HOSTED_HARNESS = "true"
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { defineAction } from "@agent-native/core";
11
11
  import { writeAppState } from "@agent-native/core/application-state";
12
+ import { getRequestUserName } from "@agent-native/core/server/request-context";
12
13
  import { resolveAccess } from "@agent-native/core/sharing";
13
14
  import { and, eq, isNull } from "drizzle-orm";
14
15
  import { z } from "zod";
@@ -93,7 +94,7 @@ export default defineAction({
93
94
  email: string;
94
95
  name?: string;
95
96
  isOrganizer?: boolean;
96
- }> = args.participants ?? [];
97
+ }> = [...(args.participants ?? [])];
97
98
  let calendarEventIdLink: string | null = null;
98
99
  let source: "calendar" | "adhoc" | "manual" =
99
100
  args.source ?? (args.title ? "manual" : "adhoc");
@@ -190,6 +191,23 @@ export default defineAction({
190
191
  }
191
192
  }
192
193
 
194
+ // Backfill a missing display name onto the owner's own attendee row —
195
+ // but only when they're already a genuine calendar attendee. Inserting
196
+ // a synthetic row for a non-attendee owner would put their email in
197
+ // meeting_participants indistinguishably from a real attendee, and that
198
+ // table is what the public share payload returns; a meeting made public
199
+ // later would leak an email the owner never actually disclosed. When the
200
+ // owner isn't an attendee, speaker resolution already has a safe
201
+ // fallback (the generic "Me" label) — no snapshot needed for that case.
202
+ const ownerName = getRequestUserName()?.trim() || undefined;
203
+ const ownerParticipant = participantsToInsert.find(
204
+ (participant) =>
205
+ participant.email.trim().toLowerCase() === ownerEmail.toLowerCase(),
206
+ );
207
+ if (ownerParticipant && !ownerParticipant.name?.trim() && ownerName) {
208
+ ownerParticipant.name = ownerName;
209
+ }
210
+
193
211
  const visibility =
194
212
  args.visibility ??
195
213
  (await getDefaultRecordingVisibility(
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Whether a persisted meeting contains something a user can reopen.
3
+ *
4
+ * Calendar events are materialized before they are recorded, so the meetings
5
+ * table also contains empty calendar rows. History should keep notes and
6
+ * completed meeting rows even when no recording was linked.
7
+ */
8
+ export interface MeetingContentFields {
9
+ recordingId?: string | null;
10
+ actualStart?: string | null;
11
+ actualEnd?: string | null;
12
+ summaryMd?: string | null;
13
+ userNotesMd?: string | null;
14
+ bulletsJson?: string | null;
15
+ actionItemsJson?: string | null;
16
+ }
17
+
18
+ export function meetingRowHasContent(row: MeetingContentFields): boolean {
19
+ return Boolean(
20
+ row.recordingId ||
21
+ row.actualStart ||
22
+ row.actualEnd ||
23
+ (row.summaryMd ?? "").trim() ||
24
+ (row.userNotesMd ?? "").trim() ||
25
+ (row.bulletsJson ?? "[]") !== "[]" ||
26
+ (row.actionItemsJson ?? "[]") !== "[]",
27
+ );
28
+ }
@@ -26,6 +26,7 @@ import {
26
26
  lt,
27
27
  gte,
28
28
  lte,
29
+ ne,
29
30
  or,
30
31
  sql,
31
32
  } from "drizzle-orm";
@@ -48,9 +49,23 @@ import {
48
49
  } from "../server/lib/calendar-event-meetings.js";
49
50
  import { listEvents } from "../server/lib/google-calendar-client.js";
50
51
  import { booleanParam } from "./lib/cli-params.js";
52
+ import { meetingRowHasContent } from "./lib/meeting-content.js";
51
53
 
52
54
  const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
53
55
 
56
+ /** SQL mirror of `meetingRowHasContent`. Keep history filtering consistent. */
57
+ function meetingHasContentFilter() {
58
+ return or(
59
+ isNotNull(schema.meetings.recordingId),
60
+ isNotNull(schema.meetings.actualStart),
61
+ isNotNull(schema.meetings.actualEnd),
62
+ sql`trim(${schema.meetings.summaryMd}) <> ''`,
63
+ sql`trim(${schema.meetings.userNotesMd}) <> ''`,
64
+ ne(schema.meetings.bulletsJson, "[]"),
65
+ ne(schema.meetings.actionItemsJson, "[]"),
66
+ )!;
67
+ }
68
+
54
69
  export default defineAction({
55
70
  description:
56
71
  "List meetings (Granola-style) the current user has access to. Connected calendars are read live; use view='upcoming' / 'past' / 'all' / 'trash' to filter by lifecycle.",
@@ -64,6 +79,11 @@ export default defineAction({
64
79
  recordedOnly: booleanParam
65
80
  .default(false)
66
81
  .describe("Only return persisted meetings that have a linked recording."),
82
+ hasContent: booleanParam
83
+ .default(false)
84
+ .describe(
85
+ "Only return persisted meetings that contain a recording, completed capture, notes, a summary, bullets, or action items.",
86
+ ),
67
87
  includeLiveCalendar: booleanParam
68
88
  .default(true)
69
89
  .describe(
@@ -109,8 +129,9 @@ export default defineAction({
109
129
  // slice(offset, offset + limit) at the end. To make that final slice
110
130
  // correct we must fetch enough rows from BOTH sources to cover the whole
111
131
  // offset + limit window before merging — fetching only `limit` would drop
112
- // events once offset > 0 or the calendar is large. Keep the hard caps
113
- // (500 persisted, 250 live) so a huge calendar can't blow up the request.
132
+ // events once offset > 0 or the calendar is large. Keep the live-event cap
133
+ // so a huge calendar can't blow up the request. Persisted history needs
134
+ // the full sentinel window so pages remain available after 500 rows.
114
135
  const windowCount = args.offset + args.limit;
115
136
  const upcomingWindowMaxIso = args.upcomingWithinMin
116
137
  ? new Date(
@@ -160,6 +181,9 @@ export default defineAction({
160
181
  if (args.recordedOnly) {
161
182
  whereClauses.push(isNotNull(schema.meetings.recordingId));
162
183
  }
184
+ if (args.hasContent) {
185
+ whereClauses.push(meetingHasContentFilter());
186
+ }
163
187
 
164
188
  const orderBy =
165
189
  args.view === "upcoming"
@@ -175,7 +199,7 @@ export default defineAction({
175
199
  .from(schema.meetings)
176
200
  .where(and(...whereClauses))
177
201
  .orderBy(...orderBy)
178
- .limit(Math.min(500, windowCount))
202
+ .limit(windowCount + 1)
179
203
  .offset(0);
180
204
 
181
205
  // Add a derived `summaryPreview` (first ~100 chars of summaryMd) so the
@@ -383,13 +407,7 @@ export default defineAction({
383
407
  if (
384
408
  liveEventEmitted &&
385
409
  meeting.source === "calendar" &&
386
- !meeting.recordingId &&
387
- !meeting.actualStart &&
388
- !meeting.actualEnd &&
389
- !(meeting.summaryMd ?? "").trim() &&
390
- !(meeting.userNotesMd ?? "").trim() &&
391
- (meeting.bulletsJson ?? "[]") === "[]" &&
392
- (meeting.actionItemsJson ?? "[]") === "[]"
410
+ !meetingRowHasContent(meeting)
393
411
  ) {
394
412
  continue;
395
413
  }
@@ -407,6 +425,10 @@ export default defineAction({
407
425
 
408
426
  const meetings = combined.slice(args.offset, args.offset + args.limit);
409
427
 
410
- return { meetings, calendarErrors };
428
+ return {
429
+ meetings,
430
+ calendarErrors,
431
+ hasMore: combined.length > args.offset + args.limit,
432
+ };
411
433
  },
412
434
  });
@@ -1,4 +1,4 @@
1
- import { IconSearch } from "@tabler/icons-react";
1
+ import { useT } from "@agent-native/core/client/i18n";
2
2
  import { useMemo } from "react";
3
3
 
4
4
  import {
@@ -9,6 +9,7 @@ import {
9
9
  } from "@/components/ui/tooltip";
10
10
  import { cn } from "@/lib/utils";
11
11
 
12
+ import { formatTranscriptTimestamp } from "../transcript/transcript-segment-row";
12
13
  import type { TranscriptSegment } from "./transcript-bubbles";
13
14
 
14
15
  const STOPWORDS = new Set<string>([
@@ -129,39 +130,61 @@ export function BulletLink({
129
130
  onJumpTo,
130
131
  children,
131
132
  }: BulletLinkProps) {
133
+ const t = useT();
132
134
  const matchIndex = useMemo(
133
135
  () => findBestSegmentMatch(bullet, segments),
134
136
  [bullet, segments],
135
137
  );
136
138
  const hasMatch = matchIndex >= 0;
139
+ const timestamp = hasMatch
140
+ ? formatTranscriptTimestamp(segments[matchIndex]!.startMs)
141
+ : null;
137
142
 
138
143
  return (
139
144
  <TooltipProvider delayDuration={200}>
140
145
  <div className="group flex items-start gap-1.5">
141
- <div className="flex-1 min-w-0">{children}</div>
142
- <Tooltip>
143
- <TooltipTrigger asChild>
144
- <button
145
- type="button"
146
- disabled={!hasMatch}
147
- onClick={() => hasMatch && onJumpTo(matchIndex)}
148
- aria-label={
149
- hasMatch ? "Jump to transcript moment" : "No matching moment"
150
- }
151
- className={cn(
152
- "shrink-0 mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded transition-opacity",
153
- hasMatch
154
- ? "opacity-0 group-hover:opacity-50 hover:!opacity-100 hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
155
- : "opacity-0 group-hover:opacity-30 text-muted-foreground/50 cursor-default",
156
- )}
157
- >
158
- <IconSearch className="h-3.5 w-3.5" />
159
- </button>
160
- </TooltipTrigger>
161
- <TooltipContent side="left">
162
- {hasMatch ? "Jump to transcript" : "No matching moment found"}
163
- </TooltipContent>
164
- </Tooltip>
146
+ <div className="flex min-w-0 flex-1">{children}</div>
147
+ {timestamp ? (
148
+ <Tooltip>
149
+ <TooltipTrigger asChild>
150
+ <button
151
+ type="button"
152
+ onClick={() => onJumpTo(matchIndex)}
153
+ aria-label={t("bulletLink.jumpToTranscript", {
154
+ time: timestamp,
155
+ })}
156
+ className={cn(
157
+ "mt-0.5 inline-flex h-5 shrink-0 items-center rounded px-1 font-mono text-[10px] tabular-nums text-muted-foreground/80 opacity-0 transition-opacity",
158
+ "group-hover:opacity-100 [@media(hover:none)]:opacity-100",
159
+ "cursor-pointer hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
160
+ )}
161
+ >
162
+ {timestamp}
163
+ </button>
164
+ </TooltipTrigger>
165
+ <TooltipContent side="top">
166
+ {t("bulletLink.jumpToTranscript", { time: timestamp })}
167
+ </TooltipContent>
168
+ </Tooltip>
169
+ ) : (
170
+ <Tooltip>
171
+ <TooltipTrigger asChild>
172
+ {/* A span, not a button — there's no action to disable, just
173
+ a status to announce, and tabIndex keeps it keyboard- and
174
+ screen-reader-reachable (a disabled button wouldn't be). */}
175
+ <span
176
+ tabIndex={0}
177
+ aria-label={t("bulletLink.noMatchingMoment")}
178
+ className="mt-0.5 inline-flex h-5 shrink-0 cursor-default items-center rounded px-1 font-mono text-[10px] tabular-nums text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 [@media(hover:none)]:opacity-100"
179
+ >
180
+ --:--
181
+ </span>
182
+ </TooltipTrigger>
183
+ <TooltipContent side="top">
184
+ {t("bulletLink.noMatchingMoment")}
185
+ </TooltipContent>
186
+ </Tooltip>
187
+ )}
165
188
  </div>
166
189
  </TooltipProvider>
167
190
  );
@@ -29,9 +29,10 @@ interface CanvasEditorProps {
29
29
  userNotesMd?: string;
30
30
  /** Save user notes. Called on blur after edit. */
31
31
  onUserNotesChange?: (next: string) => void;
32
- /** AI-generated summary (renders muted-gray). For the "ai" view. */
32
+ /** AI-generated summary — primary content, so it renders full-strength
33
+ * foreground like user notes. For the "ai" view. */
33
34
  summaryMd?: string;
34
- /** AI-generated bullets (renders muted-gray). For the "ai" view. */
35
+ /** AI-generated bullets — same weight as the summary. For the "ai" view. */
35
36
  bullets?: string[];
36
37
  /** Save AI summary when the user edits the summary section. */
37
38
  onSummaryChange?: (next: string) => void;
@@ -76,7 +77,7 @@ export function CanvasEditor({
76
77
  />
77
78
  )}
78
79
 
79
- {/* AI bullets — muted gray, with optional BulletLink wrappers */}
80
+ {/* AI bullets — with optional BulletLink wrappers */}
80
81
  {showAi && bullets.length > 0 && (
81
82
  <AiBulletsBlock bullets={bullets} renderBullet={renderBullet} />
82
83
  )}
@@ -181,7 +182,7 @@ function AiSummaryBlock({
181
182
  return (
182
183
  <div className="space-y-1.5">
183
184
  <AiTabIndicator />
184
- <p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
185
+ <p className="text-sm leading-relaxed whitespace-pre-wrap text-foreground">
185
186
  {value}
186
187
  </p>
187
188
  </div>
@@ -234,7 +235,7 @@ function AiSummaryBlock({
234
235
  >
235
236
  <p
236
237
  className={cn(
237
- "text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground rounded -mx-1 px-1 group-hover:bg-accent/30",
238
+ "text-sm leading-relaxed whitespace-pre-wrap text-foreground rounded -mx-1 px-1 group-hover:bg-accent/30",
238
239
  )}
239
240
  >
240
241
  {value}
@@ -263,7 +264,7 @@ function AiBulletsBlock({
263
264
  <ul className="space-y-1.5">
264
265
  {bullets.map((b, i) => {
265
266
  const content = (
266
- <div className="flex gap-2 text-sm leading-relaxed text-muted-foreground">
267
+ <div className="flex gap-2 text-sm leading-relaxed text-foreground">
267
268
  <span>•</span>
268
269
  <span className="flex-1">{b}</span>
269
270
  </div>