@elytracms/studio 0.0.56 → 0.0.58

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 (84) hide show
  1. package/convex/auth.ts +7 -0
  2. package/convex/users.ts +41 -14
  3. package/dist/components/ai/ai-chat-panel-lazy.js +3 -3
  4. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
  5. package/dist/components/ai/ai-chat-panel.d.ts +0 -9
  6. package/dist/components/ai/ai-chat-panel.js +199 -60
  7. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  8. package/dist/components/ai/chat-ui.d.ts +62 -10
  9. package/dist/components/ai/chat-ui.js +139 -23
  10. package/dist/components/ai/chat-ui.js.map +1 -1
  11. package/dist/components/ai/proposal-card.d.ts +30 -7
  12. package/dist/components/ai/proposal-card.js +81 -21
  13. package/dist/components/ai/proposal-card.js.map +1 -1
  14. package/dist/components/cms/detail-editor.js +12 -0
  15. package/dist/components/cms/detail-editor.js.map +1 -1
  16. package/dist/components/cms/document-diff-view.d.ts +12 -1
  17. package/dist/components/cms/document-diff-view.js +119 -42
  18. package/dist/components/cms/document-diff-view.js.map +1 -1
  19. package/dist/components/cms/rich-text-preview.d.ts +15 -0
  20. package/dist/components/cms/rich-text-preview.js +81 -0
  21. package/dist/components/cms/rich-text-preview.js.map +1 -0
  22. package/dist/components/cms/version-compare-text.d.ts +26 -0
  23. package/dist/components/cms/version-compare-text.js +60 -0
  24. package/dist/components/cms/version-compare-text.js.map +1 -1
  25. package/dist/lib/ai/anthropic-provider.d.ts +0 -12
  26. package/dist/lib/ai/anthropic-provider.js +3 -11
  27. package/dist/lib/ai/anthropic-provider.js.map +1 -1
  28. package/dist/lib/ai/apply-content.js +52 -10
  29. package/dist/lib/ai/apply-content.js.map +1 -1
  30. package/dist/lib/ai/chat-context.d.ts +74 -0
  31. package/dist/lib/ai/chat-context.js +100 -0
  32. package/dist/lib/ai/chat-context.js.map +1 -0
  33. package/dist/lib/ai/chat-loop.d.ts +70 -0
  34. package/dist/lib/ai/chat-loop.js +46 -0
  35. package/dist/lib/ai/chat-loop.js.map +1 -0
  36. package/dist/lib/ai/chat-state.d.ts +73 -57
  37. package/dist/lib/ai/chat-state.js +49 -90
  38. package/dist/lib/ai/chat-state.js.map +1 -1
  39. package/dist/lib/ai/fake-provider.d.ts +139 -6
  40. package/dist/lib/ai/fake-provider.js +261 -8
  41. package/dist/lib/ai/fake-provider.js.map +1 -1
  42. package/dist/lib/ai/gateway-provider.d.ts +30 -0
  43. package/dist/lib/ai/gateway-provider.js +73 -0
  44. package/dist/lib/ai/gateway-provider.js.map +1 -0
  45. package/dist/lib/ai/index.d.ts +5 -1
  46. package/dist/lib/ai/index.js +5 -1
  47. package/dist/lib/ai/index.js.map +1 -1
  48. package/dist/lib/ai/node-handler.d.ts +13 -4
  49. package/dist/lib/ai/node-handler.js +33 -3
  50. package/dist/lib/ai/node-handler.js.map +1 -1
  51. package/dist/lib/ai/rich-text-input.d.ts +27 -0
  52. package/dist/lib/ai/rich-text-input.js +25 -0
  53. package/dist/lib/ai/rich-text-input.js.map +1 -0
  54. package/dist/lib/ai/route-provider.d.ts +35 -10
  55. package/dist/lib/ai/route-provider.js +13 -6
  56. package/dist/lib/ai/route-provider.js.map +1 -1
  57. package/dist/lib/ai/route.d.ts +50 -21
  58. package/dist/lib/ai/route.js +69 -40
  59. package/dist/lib/ai/route.js.map +1 -1
  60. package/dist/lib/ai/shape.d.ts +12 -1
  61. package/dist/lib/ai/shape.js +46 -20
  62. package/dist/lib/ai/shape.js.map +1 -1
  63. package/dist/lib/ai/tools.d.ts +242 -37
  64. package/dist/lib/ai/tools.js +548 -63
  65. package/dist/lib/ai/tools.js.map +1 -1
  66. package/dist/lib/ai/types.d.ts +31 -16
  67. package/dist/lib/ai/types.js.map +1 -1
  68. package/dist/lib/ai/update-fields.d.ts +68 -0
  69. package/dist/lib/ai/update-fields.js +296 -0
  70. package/dist/lib/ai/update-fields.js.map +1 -0
  71. package/dist/lib/ai-current-document.d.ts +53 -0
  72. package/dist/lib/ai-current-document.js +54 -0
  73. package/dist/lib/ai-current-document.js.map +1 -0
  74. package/dist/lib/auth/convex-auth-adapter.d.ts +9 -1
  75. package/dist/lib/auth/convex-auth-adapter.js +28 -2
  76. package/dist/lib/auth/convex-auth-adapter.js.map +1 -1
  77. package/dist/lib/cms-views/view-model.d.ts +6 -6
  78. package/dist/lib/i18n/messages.en.js +19 -1
  79. package/dist/lib/i18n/messages.en.js.map +1 -1
  80. package/dist/ui/badge.d.ts +1 -1
  81. package/package.json +4 -3
  82. package/dist/lib/ai/create-flow.d.ts +0 -132
  83. package/dist/lib/ai/create-flow.js +0 -280
  84. package/dist/lib/ai/create-flow.js.map +0 -1
package/convex/auth.ts CHANGED
@@ -20,12 +20,19 @@ type ConvexAuthProviders = Parameters<typeof convexAuth>[0]['providers']
20
20
  * `ConvexError.data` across that redaction so the sign-in screen can tell the
21
21
  * truth. Codes ONLY — never the raw library message (it can carry the account
22
22
  * id / env-var names).
23
+ *
24
+ * EC-377 widened the family beyond sign-in: `users:changePassword` codes its
25
+ * provider throws with the same table (a wrong CURRENT password is an
26
+ * `InvalidSecret` → `invalid-credentials`), plus `weak-password` for its own
27
+ * min-length guard (the reset flow validates it, `modifyAccountCredentials`
28
+ * does not — and the client UI doesn't pre-validate).
23
29
  */
24
30
  export type StudioAuthErrorCode =
25
31
  | 'invalid-credentials'
26
32
  | 'too-many-attempts'
27
33
  | 'account-exists'
28
34
  | 'deployment-misconfigured'
35
+ | 'weak-password'
29
36
 
30
37
  /**
31
38
  * Map a `@convex-dev/auth` plain-`Error` message to a stable code — or `null`
package/convex/users.ts CHANGED
@@ -4,7 +4,9 @@ import {
4
4
  retrieveAccount,
5
5
  } from '@convex-dev/auth/server'
6
6
  import { makeFunctionReference } from 'convex/server'
7
- import { v } from 'convex/values'
7
+ import { ConvexError, v } from 'convex/values'
8
+ import { toCodedSignInError } from './auth'
9
+ import type { StudioAuthErrorCode } from './auth'
8
10
  import { action, mutation, query } from './functions'
9
11
  import type { QueryCtx } from './functions'
10
12
  import { requireCapability } from './guard'
@@ -146,6 +148,16 @@ const meRef = makeFunctionReference<'query', Record<string, never>, MeResult | n
146
148
  * provider and are NEVER logged or persisted by this code. Self-scoped: it
147
149
  * derives the caller from the JWT and cross-checks the retrieved account is the
148
150
  * caller's own before modifying.
151
+ *
152
+ * EC-377: the provider-call failure path re-throws through the EC-363
153
+ * `toCodedSignInError` wrap — `@convex-dev/auth` throws its failures as PLAIN
154
+ * `Error`s (`retrieveAccount` → `"InvalidSecret"` on a wrong CURRENT password),
155
+ * and a PROD deployment redacts a plain error's `.message` to "Server Error",
156
+ * so without the wrap the client's honest-generic fallback swallowed the truth.
157
+ * Recognized throws become `ConvexError({ code })` (`.data` survives prod
158
+ * redaction; codes ONLY, never the raw message); everything else — including
159
+ * this action's own `unauthorized: …` guards — passes through UNTOUCHED, so
160
+ * which cases fail is unchanged.
149
161
  */
150
162
  export const changePassword = action({
151
163
  args: { currentPassword: v.string(), newPassword: v.string() },
@@ -153,21 +165,36 @@ export const changePassword = action({
153
165
  const userId = await getAuthUserId(ctx)
154
166
  if (userId === null) throw new Error('unauthorized: this function requires an authenticated user')
155
167
  // Enforce the provider's default password policy (min length) — the reset
156
- // flow validates it, but `modifyAccountCredentials` does not.
157
- if (newPassword.length < 8) throw new Error('Password must be at least 8 characters')
168
+ // flow validates it, but `modifyAccountCredentials` does not. Coded (EC-377):
169
+ // the credential panel has no client-side length check, so this guard is
170
+ // user-reachable and must classify truthfully on prod too.
171
+ if (newPassword.length < 8) {
172
+ throw new ConvexError({ code: 'weak-password' satisfies StudioAuthErrorCode })
173
+ }
158
174
  const me = await ctx.runQuery(meRef, {})
159
175
  if (!me?.email) throw new Error('unauthorized: no account email on file')
160
- // Verify the CURRENT password; throws on mismatch (→ invalid-credentials).
161
- const { user } = await retrieveAccount(ctx, {
162
- provider: 'password',
163
- account: { id: me.email, secret: currentPassword },
164
- })
165
- // Defense in depth: the retrieved account MUST be the caller's own.
166
- if (String(user._id) !== String(userId)) throw new Error('unauthorized: account mismatch')
167
- await modifyAccountCredentials(ctx, {
168
- provider: 'password',
169
- account: { id: me.email, secret: newPassword },
170
- })
176
+ try {
177
+ // Verify the CURRENT password; throws on mismatch (→ invalid-credentials).
178
+ const { user } = await retrieveAccount(ctx, {
179
+ provider: 'password',
180
+ account: { id: me.email, secret: currentPassword },
181
+ })
182
+ // Defense in depth: the retrieved account MUST be the caller's own.
183
+ if (String(user._id) !== String(userId)) throw new Error('unauthorized: account mismatch')
184
+ await modifyAccountCredentials(ctx, {
185
+ provider: 'password',
186
+ account: { id: me.email, secret: newPassword },
187
+ })
188
+ } catch (error) {
189
+ const coded = toCodedSignInError(error)
190
+ if (coded !== error) {
191
+ // Keep the ORIGINAL failure visible in the deployment logs — the client
192
+ // only receives the stable code. (`retrieveAccount` throws bare token
193
+ // messages; the raw password values never appear in any error.)
194
+ console.error('users:changePassword failed:', error)
195
+ }
196
+ throw coded
197
+ }
171
198
  return null
172
199
  },
173
200
  })
@@ -3,9 +3,9 @@ import { Suspense, lazy } from 'react';
3
3
  import { Text } from '@elytracms/studio/ui';
4
4
  import { translate } from '@elytracms/studio/lib/i18n';
5
5
  /**
6
- * Lazy boundary for the AI chat panel (EC-199, EC-331 bundle discipline). The panel
7
- * and everything it imports — the creation orchestrator, `applyContentWithShape`, the
8
- * provider adapters (and the AI SDK behind the Anthropic one via dynamic import) — load
6
+ * Lazy boundary for the AI chat panel (EC-199, EC-331/EC-334 bundle discipline). The
7
+ * panel and everything it imports — `useChat` + the AI SDK (`ai` / `@ai-sdk/react`),
8
+ * the client tool executor, `applyContentWithShape`, the provider adapters — load
9
9
  * ONLY when a configured studio actually opens the panel. An unconfigured studio never
10
10
  * renders the toggle, never mounts this, and so pulls none of the AI chunk onto the CMS
11
11
  * critical path (pinned by the `lib/ai` bundle test). Same pattern as
@@ -1 +1 @@
1
- {"version":3,"file":"ai-chat-panel-lazy.js","sourceRoot":"","sources":["../../../src/components/ai/ai-chat-panel-lazy.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD;;;;;;;;GAQG;AACH,MAAM,eAAe,GAAG,IAAI,CAAC,GAAG,EAAE,CAChC,MAAM,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC,CAC9E,CAAA;AAED,SAAS,eAAe;IACtB,OAAO,CACL,gBAAO,SAAS,EAAC,gFAAgF,YAC/F,cAAK,SAAS,EAAC,4CAA4C,YACzD,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,kBAAkB,CAAC,GACzB,GACH,GACA,CACT,CAAA;AACH,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,OAAO,CACL,KAAC,QAAQ,IAAC,QAAQ,EAAE,KAAC,eAAe,KAAG,YACrC,KAAC,eAAe,KAAG,GACV,CACZ,CAAA;AACH,CAAC","sourcesContent":["import { Suspense, lazy } from 'react'\nimport { Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * Lazy boundary for the AI chat panel (EC-199, EC-331 bundle discipline). The panel\n * and everything it imports — the creation orchestrator, `applyContentWithShape`, the\n * provider adapters (and the AI SDK behind the Anthropic one via dynamic import) — load\n * ONLY when a configured studio actually opens the panel. An unconfigured studio never\n * renders the toggle, never mounts this, and so pulls none of the AI chunk onto the CMS\n * critical path (pinned by the `lib/ai` bundle test). Same pattern as\n * `version-compare-lazy` / `rich-text/lazy`.\n */\nconst AiChatPanelImpl = lazy(() =>\n import('./ai-chat-panel').then((module) => ({ default: module.AiChatPanel })),\n)\n\nfunction AiPanelFallback() {\n return (\n <aside className=\"flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card\">\n <div className=\"grid min-h-0 flex-1 place-items-center p-6\">\n <Text size=\"sm\" tone=\"muted\">\n {translate('ai.panel.loading')}\n </Text>\n </div>\n </aside>\n )\n}\n\nexport function AiChatPanel() {\n return (\n <Suspense fallback={<AiPanelFallback />}>\n <AiChatPanelImpl />\n </Suspense>\n )\n}\n"]}
1
+ {"version":3,"file":"ai-chat-panel-lazy.js","sourceRoot":"","sources":["../../../src/components/ai/ai-chat-panel-lazy.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD;;;;;;;;GAQG;AACH,MAAM,eAAe,GAAG,IAAI,CAAC,GAAG,EAAE,CAChC,MAAM,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC,CAC9E,CAAA;AAED,SAAS,eAAe;IACtB,OAAO,CACL,gBAAO,SAAS,EAAC,gFAAgF,YAC/F,cAAK,SAAS,EAAC,4CAA4C,YACzD,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,kBAAkB,CAAC,GACzB,GACH,GACA,CACT,CAAA;AACH,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,OAAO,CACL,KAAC,QAAQ,IAAC,QAAQ,EAAE,KAAC,eAAe,KAAG,YACrC,KAAC,eAAe,KAAG,GACV,CACZ,CAAA;AACH,CAAC","sourcesContent":["import { Suspense, lazy } from 'react'\nimport { Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * Lazy boundary for the AI chat panel (EC-199, EC-331/EC-334 bundle discipline). The\n * panel and everything it imports — `useChat` + the AI SDK (`ai` / `@ai-sdk/react`),\n * the client tool executor, `applyContentWithShape`, the provider adapters — load\n * ONLY when a configured studio actually opens the panel. An unconfigured studio never\n * renders the toggle, never mounts this, and so pulls none of the AI chunk onto the CMS\n * critical path (pinned by the `lib/ai` bundle test). Same pattern as\n * `version-compare-lazy` / `rich-text/lazy`.\n */\nconst AiChatPanelImpl = lazy(() =>\n import('./ai-chat-panel').then((module) => ({ default: module.AiChatPanel })),\n)\n\nfunction AiPanelFallback() {\n return (\n <aside className=\"flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card\">\n <div className=\"grid min-h-0 flex-1 place-items-center p-6\">\n <Text size=\"sm\" tone=\"muted\">\n {translate('ai.panel.loading')}\n </Text>\n </div>\n </aside>\n )\n}\n\nexport function AiChatPanel() {\n return (\n <Suspense fallback={<AiPanelFallback />}>\n <AiChatPanelImpl />\n </Suspense>\n )\n}\n"]}
@@ -1,10 +1 @@
1
- /**
2
- * The AI assistant chat panel (EC-199) — the right-hand collapsible surface in the CMS
3
- * area. It owns the chat state (the pure `chatReducer`), resolves the EC-331 provider
4
- * from `defineStudio({ ai })`, binds the narrow tools (search/list) to the operations
5
- * layer with the user's session, runs the creation orchestrator, and applies a
6
- * reviewed proposal as a DRAFT (operations upsert + a provenance-tagged version
7
- * snapshot). NEVER a modal (house rule). The whole module sits behind a lazy boundary
8
- * (`ai-chat-panel-lazy`), so an unconfigured studio pulls none of it — and no AI SDK.
9
- */
10
1
  export declare function AiChatPanel(): import("react").JSX.Element;
@@ -1,39 +1,64 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useMemo, useReducer, useState } from 'react';
3
- import { X } from 'lucide-react';
2
+ import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
3
+ import { MessageSquarePlus, X } from 'lucide-react';
4
+ import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
5
+ import { useChat } from '@ai-sdk/react';
4
6
  import { createCollectionRegistry, resolveDocumentTitle, } from '@elytracms/core/cms-core';
5
7
  import { Button, Heading, Row, Spacer, Text } from '@elytracms/studio/ui';
6
8
  import { translate } from '@elytracms/studio/lib/i18n';
7
9
  import { STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router';
8
10
  import { resolveDeploymentAi } from '@elytracms/studio/lib/workspace';
9
11
  import { getStudioOperations } from '@elytracms/studio/lib/operations';
10
- import { AiRouteError, chatReducer, initialChatState, resolveAiConfig, resolveKnowledge, runCreateFlow, } from '@elytracms/studio/lib/ai';
12
+ import { DEFAULT_MAX_STEPS, buildChatContext, chatReviewReducer, clearConversation, createChatCandidateStore, createChatToolExecutor, initialChatReviewState, renderCurrentDocumentSection, resolveAiConfig, resolveChatTarget, resolveKnowledge, withStepCap, } from '@elytracms/studio/lib/ai';
13
+ import { getAiCurrentDocument } from '@elytracms/studio/lib/ai-current-document';
11
14
  import { useCms } from '../cms/cms-store.js';
12
15
  import { validateComposition, validateRichTextEmbeds } from '../cms/cms-composition-validators.js';
13
- import { AssistantTurn, Conversation, ConversationEmpty, PromptInput, ToolStatus, UserTurn, } from './chat-ui.js';
16
+ import { AssistantTurn, ChatErrorNote, Conversation, ConversationEmpty, PromptInput, ToolStatus, UserTurn, WorkingIndicator, describeToolPart, messageText, segmentMessageParts, } from './chat-ui.js';
14
17
  import { ProposalCard } from './proposal-card.js';
15
18
  import { setAiPanelOpen } from '@elytracms/studio/lib/ai-panel';
16
19
  /**
17
- * The AI assistant chat panel (EC-199) — the right-hand collapsible surface in the CMS
18
- * area. It owns the chat state (the pure `chatReducer`), resolves the EC-331 provider
19
- * from `defineStudio({ ai })`, binds the narrow tools (search/list) to the operations
20
- * layer with the user's session, runs the creation orchestrator, and applies a
21
- * reviewed proposal as a DRAFT (operations upsert + a provenance-tagged version
22
- * snapshot). NEVER a modal (house rule). The whole module sits behind a lazy boundary
23
- * (`ai-chat-panel-lazy`), so an unconfigured studio pulls none of it — and no AI SDK.
20
+ * The AI assistant chat panel (EC-199, moved onto the SDK's native protocol by
21
+ * EC-334) — the right-hand collapsible surface in the CMS area. `useChat` +
22
+ * `DefaultChatTransport` own the conversation against the route's native chat
23
+ * branch (visible token streaming); `onToolCall` dispatches into the client
24
+ * executor map (operations reads + terminal engines, all with the user's own
25
+ * session); `sendAutomaticallyWhen` is the SDK predicate wrapped with the
26
+ * per-turn step cap. What the SDK does not own lives in the pure review reducer:
27
+ * full proposals keyed by `toolCallId` (reviewed as a diff, applied as a DRAFT
28
+ * via operations upsert + a provenance-tagged snapshot — the EC-199 trust
29
+ * boundary, unmoved) and blocking errors. EC-380: the "new conversation"
30
+ * affordance clears busy-safely (stop → new chat id + review reset; pending
31
+ * proposals are discarded with reject semantics).
32
+ *
33
+ * NEVER a modal (house rule). The whole module (including `ai` + `@ai-sdk/react`)
34
+ * sits behind the lazy boundary (`ai-chat-panel-lazy`) — pinned in bundle tests.
24
35
  */
36
+ /** Adapt a `RouteHeaders` (static or thunk) to the transport's headers resolvable. */
37
+ function toTransportHeaders(headers) {
38
+ return async () => {
39
+ const resolved = typeof headers === 'function' ? await headers() : headers;
40
+ const record = {};
41
+ new Headers(resolved ?? {}).forEach((value, key) => {
42
+ record[key] = value;
43
+ });
44
+ return record;
45
+ };
46
+ }
25
47
  export function AiChatPanel() {
26
48
  const cms = useCms();
27
49
  const navigate = useNavigate();
28
- const [state, dispatch] = useReducer(chatReducer, initialChatState);
50
+ const [review, dispatch] = useReducer(chatReviewReducer, initialChatReviewState);
29
51
  const [input, setInput] = useState('');
52
+ // EC-380: the conversation counter — bumping it mints a fresh `useChat` id
53
+ // (a brand-new conversation) and a fresh relation-candidate store.
54
+ const [conversation, setConversation] = useState(1);
30
55
  const projectId = cms.projectId;
31
56
  const activeLocale = cms.activeLocale;
32
57
  // Resolve the AI platform config once (presence + capability gate). The panel only
33
58
  // mounts when configured, but a misconfigured provider still fails loud here.
34
59
  const resolved = useMemo(() => resolveAiConfig(resolveDeploymentAi()), []);
35
60
  const provider = resolved.resolved?.provider ?? null;
36
- const knowledgeSources = resolved.resolved?.knowledge ?? [];
61
+ const knowledgeSources = useMemo(() => resolved.resolved?.knowledge ?? [], [resolved]);
37
62
  const maxRepairAttempts = resolved.resolved?.limits?.maxRepairAttempts;
38
63
  const ready = provider !== null && resolved.ok && projectId !== undefined;
39
64
  const registry = useMemo(() => createCollectionRegistry(cms.collections), [cms.collections]);
@@ -43,7 +68,7 @@ export function AiChatPanel() {
43
68
  ...(doc.localized?.[activeLocale] ?? {}),
44
69
  }), [activeLocale]);
45
70
  /** Operations-backed tool readers — client-side, with the user's own session. */
46
- const tools = useMemo(() => {
71
+ const readers = useMemo(() => {
47
72
  const matches = (query, ...fields) => !query || fields.some((f) => f?.toLowerCase().includes(query.toLowerCase()));
48
73
  return {
49
74
  async searchDocuments({ collection, query }) {
@@ -74,7 +99,7 @@ export function AiChatPanel() {
74
99
  .map((asset) => ({ id: asset.id, title: asset.title ?? asset.filename }))
75
100
  .filter((r) => matches(query, r.title, r.id));
76
101
  },
77
- // EC-332: the translate flow loads a full document (draft values + localized).
102
+ // Full document (draft values + localized) — the getDocument/translate tools.
78
103
  async getDocument({ collection, id }) {
79
104
  if (projectId === undefined)
80
105
  return null;
@@ -110,57 +135,154 @@ export function AiChatPanel() {
110
135
  })
111
136
  .filter((item) => item.content.trim().length > 0);
112
137
  }, [projectId, cms, localeValues]);
113
- const runTurn = useCallback(async (text) => {
114
- if (!provider)
115
- return;
116
- try {
138
+ // The knowledge snapshot the executors read — refreshed by the body thunk on
139
+ // every request, so the terminal engines see what the model saw this turn.
140
+ const knowledgeRef = useRef({ items: [] });
141
+ // The chat transport target: the configured route provider's endpoint +
142
+ // session-header thunk (EC-371), re-evaluated per request.
143
+ const chatTarget = useMemo(() => resolveChatTarget(provider), [provider]);
144
+ const transport = useMemo(() => new DefaultChatTransport({
145
+ api: chatTarget.endpoint,
146
+ headers: toTransportHeaders(chatTarget.headers),
147
+ // The `body` THUNK — re-evaluated per request (fresh context every turn,
148
+ // continuations included): collections-catalog summary + client-resolved
149
+ // knowledge + EC-378's current-document block (read from the seam store
150
+ // at REQUEST time, so every turn sees what is open NOW). The route folds
151
+ // these sections into the system prompt; it resolves no data itself
152
+ // (zero-data-access proxy). The block is advisory — the deterministic
153
+ // omitted-target fallback lives in the executor.
154
+ body: async () => {
117
155
  const knowledge = await resolveKnowledge(knowledgeSources, { readCollection });
118
- const result = await runCreateFlow({
119
- provider,
120
- tools,
121
- registry,
122
- collections: cms.collections,
123
- instruction: text,
124
- knowledge,
125
- locale: activeLocale,
126
- localeConfig: cms.localeConfig,
127
- mintDocumentId,
128
- validateRichTextEmbeds,
129
- validateComposition,
130
- ...(maxRepairAttempts !== undefined ? { maxRepairAttempts } : {}),
131
- onStep: (step) => dispatch({ type: 'step', step }),
132
- });
133
- dispatch({ type: 'result', result });
134
- }
135
- catch (error) {
136
- const message = error instanceof AiRouteError
137
- ? translate('ai.error.route')
138
- : translate('ai.error.generic');
139
- dispatch({ type: 'fail', message });
140
- }
156
+ knowledgeRef.current = knowledge;
157
+ const current = getAiCurrentDocument();
158
+ return {
159
+ context: buildChatContext({
160
+ collections: cms.collections,
161
+ knowledge,
162
+ ...(current
163
+ ? {
164
+ extraSections: [
165
+ renderCurrentDocumentSection({
166
+ collection: current.collection,
167
+ id: current.id,
168
+ title: current.title,
169
+ locale: activeLocale,
170
+ }),
171
+ ],
172
+ }
173
+ : {}),
174
+ }),
175
+ };
176
+ },
177
+ }), [chatTarget, knowledgeSources, readCollection, cms.collections, activeLocale]);
178
+ // Per-conversation relation-candidate store (EC-380: replaced on clear, so a
179
+ // fresh conversation cannot reference the previous one's searched ids).
180
+ const candidatesRef = useRef({
181
+ conversation,
182
+ store: createChatCandidateStore(),
183
+ });
184
+ if (candidatesRef.current.conversation !== conversation) {
185
+ candidatesRef.current = { conversation, store: createChatCandidateStore() };
186
+ }
187
+ const candidateStore = candidatesRef.current.store;
188
+ const executor = useMemo(() => {
189
+ if (!provider)
190
+ return null;
191
+ return createChatToolExecutor({
192
+ provider,
193
+ registry,
194
+ collections: cms.collections,
195
+ readers,
196
+ mintDocumentId,
197
+ candidates: candidateStore,
198
+ onProposal: (toolCallId, proposal) => dispatch({ type: 'propose', toolCallId, proposal }),
199
+ knowledge: () => knowledgeRef.current,
200
+ // EC-378: the current-document seam, read at EXECUTION time — the
201
+ // deterministic fallback when the model omits a tool's target.
202
+ currentDocument: () => getAiCurrentDocument(),
203
+ locale: activeLocale,
204
+ ...(cms.localeConfig ? { localeConfig: cms.localeConfig } : {}),
205
+ validateRichTextEmbeds,
206
+ validateComposition,
207
+ ...(maxRepairAttempts !== undefined ? { maxRepairAttempts } : {}),
208
+ });
141
209
  }, [
142
210
  provider,
143
- knowledgeSources,
144
- readCollection,
145
- tools,
146
211
  registry,
147
212
  cms.collections,
148
- activeLocale,
213
+ cms.localeConfig,
214
+ readers,
149
215
  mintDocumentId,
216
+ activeLocale,
150
217
  maxRepairAttempts,
218
+ candidateStore,
151
219
  ]);
220
+ const chat = useChat({
221
+ // A new conversation is a new chat id — `useChat` recreates its state.
222
+ id: `elytra-assistant-${conversation}`,
223
+ transport,
224
+ onToolCall: async ({ toolCall }) => {
225
+ if (!executor)
226
+ return;
227
+ const outcome = await executor.execute({
228
+ toolCallId: toolCall.toolCallId,
229
+ toolName: toolCall.toolName,
230
+ input: toolCall.input,
231
+ });
232
+ // Deliberately NOT awaited: `onToolCall` runs inside the chat's serial
233
+ // stream job, and `addToolOutput` enqueues on the same executor —
234
+ // awaiting it here deadlocks the stream (verified against ai@7.0.22).
235
+ if (outcome.ok) {
236
+ void chat.addToolOutput({
237
+ tool: toolCall.toolName,
238
+ toolCallId: toolCall.toolCallId,
239
+ output: outcome.output,
240
+ });
241
+ }
242
+ else {
243
+ void chat.addToolOutput({
244
+ state: 'output-error',
245
+ tool: toolCall.toolName,
246
+ toolCallId: toolCall.toolCallId,
247
+ errorText: outcome.errorText,
248
+ });
249
+ }
250
+ },
251
+ // The SDK predicate wrapped with the per-user-turn step cap: a capped turn
252
+ // stops cleanly with a visible note — never a silent stall (EC-334).
253
+ sendAutomaticallyWhen: withStepCap(lastAssistantMessageIsCompleteWithToolCalls, {
254
+ maxSteps: DEFAULT_MAX_STEPS,
255
+ onCap: () => dispatch({ type: 'fail', message: translate('ai.error.stepCap', { max: DEFAULT_MAX_STEPS }) }),
256
+ }),
257
+ onError: () => dispatch({ type: 'fail', message: translate('ai.error.route') }),
258
+ });
259
+ const busy = chat.status === 'submitted' || chat.status === 'streaming';
152
260
  const submit = useCallback(() => {
153
261
  const text = input.trim();
154
- if (text.length === 0 || state.status === 'busy' || !ready)
262
+ if (text.length === 0 || busy || !ready)
155
263
  return;
156
264
  setInput('');
157
- dispatch({ type: 'submit', text });
158
- void runTurn(text);
159
- }, [input, state.status, ready, runTurn]);
265
+ dispatch({ type: 'clear-error' });
266
+ void chat.sendMessage({ text });
267
+ }, [input, busy, ready, chat]);
268
+ // EC-380: clear / new topic — busy-safe (stop first), pending proposals
269
+ // discarded with reject semantics (the review state resets; nothing was applied).
270
+ const startNewConversation = useCallback(() => {
271
+ void clearConversation({ status: chat.status, stop: chat.stop }, () => {
272
+ dispatch({ type: 'clear' });
273
+ setInput('');
274
+ setConversation((current) => current + 1);
275
+ });
276
+ }, [chat.status, chat.stop]);
160
277
  const applyProposal = useCallback(async (proposal) => {
161
278
  if (projectId === undefined)
162
279
  return;
280
+ // For an update/translate of an EXISTING document, spread the stored doc
281
+ // first so document-level extras (alias, defaultLocale) survive the apply —
282
+ // the patch replaces only values/localized.
283
+ const existing = cms.getDocument({ collection: proposal.collection, id: proposal.documentId });
163
284
  const doc = {
285
+ ...(existing ?? {}),
164
286
  collection: proposal.collection,
165
287
  id: proposal.documentId,
166
288
  values: proposal.patch.values,
@@ -188,7 +310,16 @@ export function AiChatPanel() {
188
310
  }
189
311
  // Reflect in the session so the new draft shows in the list + is navigable.
190
312
  cms.saveDocument(doc);
191
- dispatch({ type: 'apply', proposalId: proposal.id });
313
+ // EC-378: when the applied proposal targets the document OPEN in the editor,
314
+ // adopt it into the editor's draft buffer — otherwise the editor's autosave
315
+ // would write its stale buffer back and silently revert the applied change.
316
+ const current = getAiCurrentDocument();
317
+ if (current &&
318
+ current.collection === proposal.collection &&
319
+ current.id === proposal.documentId) {
320
+ current.adoptDraft(doc);
321
+ }
322
+ dispatch({ type: 'apply', toolCallId: proposal.id });
192
323
  }, [projectId, cms]);
193
324
  const openProposal = useCallback((proposal) => {
194
325
  if (projectId === undefined)
@@ -198,14 +329,22 @@ export function AiChatPanel() {
198
329
  params: { projectId, _splat: studioPath.cmsDocument(proposal.collection, proposal.documentId) },
199
330
  });
200
331
  }, [navigate, projectId]);
201
- return (_jsxs("aside", { className: "flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card", children: [_jsxs(Row, { gap: "2", align: "center", className: "h-12 shrink-0 border-b border-border px-3", children: [_jsx(Heading, { level: 2, size: "h4", children: translate('ai.panel.title') }), _jsx(Spacer, {}), _jsx(Button, { size: "icon-sm", variant: "ghost", onClick: () => setAiPanelOpen(false), title: translate('ai.panel.close'), "aria-label": translate('ai.panel.close'), children: _jsx(X, {}) })] }), _jsxs(Conversation, { atBottomKey: state.messages, children: [state.messages.length === 0 ? _jsx(ConversationEmpty, {}) : null, state.messages.map((message) => {
202
- if (message.role === 'user')
203
- return _jsx(UserTurn, { text: message.text }, message.id);
204
- if (message.kind === 'text')
205
- return (_jsx(AssistantTurn, { children: _jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: message.text }) }, message.id));
206
- if (message.kind === 'activity')
207
- return (_jsx(AssistantTurn, { children: _jsx(ToolStatus, { steps: message.steps, active: state.status === 'busy' && state.activityId === message.id }) }, message.id));
208
- return (_jsx(ProposalCard, { proposal: message.proposal, onApply: () => void applyProposal(message.proposal), onDiscard: () => dispatch({ type: 'reject', proposalId: message.proposal.id }), onOpen: () => openProposal(message.proposal) }, message.id));
209
- })] }), ready ? (_jsx(PromptInput, { value: input, onChange: setInput, onSubmit: submit, disabled: state.status === 'busy' })) : (_jsx("div", { className: "border-t border-border p-3", children: _jsx(Text, { size: "xs", tone: "muted", children: translate('ai.panel.unavailable') }) }))] }));
332
+ const hasProposal = useCallback((toolCallId) => review.proposals[toolCallId] !== undefined, [review.proposals]);
333
+ const renderAssistantMessage = (message) => {
334
+ const segments = segmentMessageParts(message.parts, hasProposal);
335
+ return segments.map((segment, index) => {
336
+ if (segment.kind === 'text') {
337
+ return (_jsx(AssistantTurn, { children: _jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: segment.text }) }, `${message.id}-${index}`));
338
+ }
339
+ if (segment.kind === 'tools') {
340
+ return (_jsx(AssistantTurn, { children: _jsx(ToolStatus, { steps: segment.parts.map(describeToolPart) }) }, `${message.id}-${index}`));
341
+ }
342
+ const proposal = review.proposals[segment.toolCallId];
343
+ if (!proposal)
344
+ return null;
345
+ return (_jsx(ProposalCard, { proposal: proposal, onApply: () => void applyProposal(proposal), onDiscard: () => dispatch({ type: 'reject', toolCallId: proposal.id }), onOpen: () => openProposal(proposal) }, segment.toolCallId));
346
+ });
347
+ };
348
+ return (_jsxs("aside", { className: "flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card", children: [_jsxs(Row, { gap: "2", align: "center", className: "h-12 shrink-0 border-b border-border px-3", children: [_jsx(Heading, { level: 2, size: "h4", children: translate('ai.panel.title') }), _jsx(Spacer, {}), _jsx(Button, { size: "icon-sm", variant: "ghost", onClick: startNewConversation, disabled: chat.messages.length === 0 && review.error === null && !busy, title: translate('ai.panel.newConversation'), "aria-label": translate('ai.panel.newConversation'), children: _jsx(MessageSquarePlus, {}) }), _jsx(Button, { size: "icon-sm", variant: "ghost", onClick: () => setAiPanelOpen(false), title: translate('ai.panel.close'), "aria-label": translate('ai.panel.close'), children: _jsx(X, {}) })] }), _jsxs(Conversation, { atBottomKey: chat.messages, children: [chat.messages.length === 0 ? _jsx(ConversationEmpty, {}) : null, chat.messages.map((message) => message.role === 'user' ? (_jsx(UserTurn, { text: messageText(message) }, message.id)) : (_jsx("div", { className: "contents", children: renderAssistantMessage(message) }, message.id))), chat.status === 'submitted' ? (_jsx(AssistantTurn, { children: _jsx(WorkingIndicator, {}) })) : null, review.error !== null ? (_jsx(AssistantTurn, { children: _jsx(ChatErrorNote, { message: review.error }) })) : null] }), ready ? (_jsx(PromptInput, { value: input, onChange: setInput, onSubmit: submit, disabled: busy })) : (_jsx("div", { className: "border-t border-border p-3", children: _jsx(Text, { size: "xs", tone: "muted", children: translate('ai.panel.unavailable') }) }))] }));
210
349
  }
211
350
  //# sourceMappingURL=ai-chat-panel.js.map