@things-factory/figure-ui 10.1.167 → 10.1.169

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 (48) hide show
  1. package/client/figure-assistant.ts +15 -1
  2. package/client/modeller/ai-proposal.ts +60 -11
  3. package/client/modeller/figure-properties.ts +1 -3
  4. package/client/modeller/product-session.ts +32 -13
  5. package/client/modeller/refusals.ts +14 -0
  6. package/client/pages/figure-modeller-page.ts +14 -10
  7. package/dist-client/figure-assistant.d.ts +6 -0
  8. package/dist-client/figure-assistant.js +14 -1
  9. package/dist-client/figure-assistant.js.map +1 -1
  10. package/dist-client/modeller/ai-proposal.d.ts +23 -5
  11. package/dist-client/modeller/ai-proposal.js +55 -13
  12. package/dist-client/modeller/ai-proposal.js.map +1 -1
  13. package/dist-client/modeller/clips.d.ts +1 -1
  14. package/dist-client/modeller/figure-properties.d.ts +1 -2
  15. package/dist-client/modeller/figure-properties.js +1 -3
  16. package/dist-client/modeller/figure-properties.js.map +1 -1
  17. package/dist-client/modeller/product-session.d.ts +14 -4
  18. package/dist-client/modeller/product-session.js +31 -13
  19. package/dist-client/modeller/product-session.js.map +1 -1
  20. package/dist-client/modeller/refusals.d.ts +1 -5
  21. package/dist-client/modeller/refusals.js +14 -1
  22. package/dist-client/modeller/refusals.js.map +1 -1
  23. package/dist-client/pages/figure-modeller-page.js +14 -9
  24. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  25. package/dist-client/route.d.ts +1 -1
  26. package/dist-client/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +7 -7
  28. package/test/ai-proposal.test.ts +115 -5
  29. package/test/browser/acceptance-page.js +3 -1
  30. package/test/browser/product-page.check.mjs +63 -3
  31. package/test/browser/product-page.js +7 -2
  32. package/test/figure-assistant-surface.test.ts +65 -1
  33. package/test/proposal-record.test.ts +18 -6
  34. package/translations/en.json +6 -12
  35. package/translations/es.json +6 -12
  36. package/translations/ja.json +6 -12
  37. package/translations/ko.json +6 -12
  38. package/translations/ms.json +6 -12
  39. package/translations/zh.json +6 -12
  40. package/client/modeller/carrier-declaration.ts +0 -63
  41. package/client/modeller/figure-carrier.ts +0 -117
  42. package/dist-client/modeller/carrier-declaration.d.ts +0 -31
  43. package/dist-client/modeller/carrier-declaration.js +0 -53
  44. package/dist-client/modeller/carrier-declaration.js.map +0 -1
  45. package/dist-client/modeller/figure-carrier.d.ts +0 -26
  46. package/dist-client/modeller/figure-carrier.js +0 -123
  47. package/dist-client/modeller/figure-carrier.js.map +0 -1
  48. package/test/carrier-declaration.test.ts +0 -69
@@ -10,11 +10,21 @@ import { i18next } from '@operato/i18n'
10
10
  import { navigate } from '@operato/shell'
11
11
  import { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js'
12
12
 
13
+ import { fetchProposalOutcome } from './graphql/index.js'
13
14
  import { modellerPathOf } from './modeller/modeller-route.js'
14
- import { pendNewFigure, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'
15
+ import { pendNewFigure, proposalOutcomeFrom, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'
15
16
 
16
17
  export const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'
17
18
 
19
+ /**
20
+ * The dock's "check the result" of a figure proposal, on every screen that offers its card: what the server recorded of
21
+ * its save (ADR-0097 §5.6 item 5). The modeller asks it, and so does an app's home where a new figure's card is made --
22
+ * there it was missing, and a card cut off by a reload stayed "no answer" for good (figure audit 2026-10-16 N8).
23
+ */
24
+ export async function figureProposalOutcome(proposalKey: string) {
25
+ return proposalOutcomeFrom(await fetchProposalOutcome(proposalKey), (key, params) => i18next.t(key, params))
26
+ }
27
+
18
28
  registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
19
29
  const outcome = await runFigureProposal(
20
30
  proposal,
@@ -27,6 +37,10 @@ registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
27
37
  const opened = pendNewFigure(steps, key)
28
38
  navigate(modellerPathOf())
29
39
  return opened
40
+ },
41
+ savedInto: fetchProposalOutcome,
42
+ openSaved(id) {
43
+ navigate(modellerPathOf({ id }))
30
44
  }
31
45
  },
32
46
  (key, params) => i18next.t(key, params)
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { FigureProposalStep } from '@hatiolab/figure-model'
9
9
  import type { FigureProductSession } from './product-session.js'
10
- import { clipLossOf, refusalRecordOf, type FigureRefusalRecord } from './refusals.js'
10
+ import { clipLossOf, editRefusalOf, refusalRecordOf, type FigureRefusalRecord } from './refusals.js'
11
11
 
12
12
  /** What the card carries, as `proposeFigureCandidate` returned it. */
13
13
  export interface FigureProposalCard {
@@ -28,6 +28,8 @@ export interface FigureApplyDetail extends FigureRefusalRecord {
28
28
  key?: string
29
29
  steps: FigureProposalStep[]
30
30
  create: boolean
31
+ /** The figure a revision was made for: the one the model read. */
32
+ figureId?: string
31
33
  context?: string
32
34
  revision?: number
33
35
  applied?: boolean
@@ -41,6 +43,10 @@ export interface FigureProposalTarget {
41
43
  offer(detail: FigureApplyDetail): FigureApplyDetail
42
44
  /** Opens a new figure in the modeller and applies the steps there; resolves once the page has, or has not. */
43
45
  openNew(steps: FigureProposalStep[], key?: string): Promise<{ applied: boolean; error?: string; code?: string }>
46
+ /** The figures the server recorded a save of this proposal into (`figureProposalOutcome`), first saved first. */
47
+ savedInto(key: string): Promise<ReadonlyArray<{ figureId: string; type?: string; name?: string }>>
48
+ /** Opens a saved figure in the modeller. */
49
+ openSaved(figureId: string): void
44
50
  }
45
51
 
46
52
  export interface FigureProposalOutcome {
@@ -78,7 +84,8 @@ function refused(refusal: FigureRefusalRecord & { error?: string }, say: Say): F
78
84
  ? (error ?? '')
79
85
  : clips
80
86
  ? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })
81
- : say('figure.text.proposal-refused', { reason: error ?? '' })
87
+ : /* A refusal with a code is said from it, as the modeller says an edit it refuses; never its message (audit N6). */
88
+ say('figure.text.proposal-refused', { reason: code ? editRefusalOf(refusal, (key, params) => say(key, params)) : (error ?? '') })
82
89
  return { verdict: 'refused', errorCode: code ?? 'refused', note }
83
90
  }
84
91
 
@@ -95,6 +102,12 @@ export function takeFigureOffer(
95
102
  ): boolean {
96
103
  const view = session.read()
97
104
  if (detail.create && onSavedFigure) return false
105
+ /*
106
+ A revision is for the figure the model read; with another open it is left untaken, and the handler says it needs that
107
+ figure open. The version is no proof of the figure: two figures opened in two page loads stood at the same one
108
+ (figure audit 2026-10-16 N3).
109
+ */
110
+ if (!detail.create && typeof detail.figureId === 'string' && detail.figureId !== (view.id ?? view.creationId)) return false
98
111
  if (detail.create && view.dirty) {
99
112
  /* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */
100
113
  detail.applied = false
@@ -104,8 +117,11 @@ export function takeFigureOffer(
104
117
  }
105
118
  try {
106
119
  if (detail.create) {
107
- /* Created under the proposal's key: taken twice, it meets the first figure, never a second (ADR-0097 §5.6 item 5). */
108
- if (detail.key) session.createUnder(detail.key)
120
+ /*
121
+ Taken on the new figure the modeller has open, whose conversation asked for it: the figure keeps its own creation
122
+ id, the anchor of that conversation (ADR-0061 decision 3, ruled 2026-10-16). One proposal stays one figure by the
123
+ save's record of it, which `runFigureProposal` reads before the card runs again.
124
+ */
109
125
  session.applyProposal(detail.steps, view.revision!, view.context!, detail.key)
110
126
  } else if (detail.steps) session.applyProposal(detail.steps, detail.revision!, detail.context!, detail.key)
111
127
  else session.apply(detail.changes as never, 'ai', detail.revision, detail.context)
@@ -121,15 +137,26 @@ export function takeFigureOffer(
121
137
 
122
138
  export async function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome> {
123
139
  const steps = card?.steps
124
- if (!Array.isArray(steps) || !steps.length) return { verdict: 'unavailable', errorCode: 'no-steps' }
140
+ if (!Array.isArray(steps) || !steps.length) return { verdict: 'unavailable', errorCode: 'no-steps', note: say('figure.text.proposal-empty') }
125
141
  const create = card.create === true
142
+ /*
143
+ A new figure's proposal already saved into a figure is not made again: that figure is opened (ADR-0061 decision 3,
144
+ ruled 2026-10-16). Taken on a new figure that kept its own creation id, the save's record is what says it was made.
145
+ */
146
+ if (create && card.key) {
147
+ const saved = await target.savedInto(card.key)
148
+ if (saved.length) {
149
+ target.openSaved(saved[0]!.figureId)
150
+ return proposalOutcomeFrom(saved, say)!
151
+ }
152
+ }
126
153
  const accepted = (): FigureProposalOutcome => ({
127
154
  verdict: 'accepted',
128
155
  applied: steps.length,
129
156
  note: say('figure.text.proposal-applied', { n: String(steps.length) })
130
157
  })
131
158
 
132
- const detail = target.offer({ key: card.key, steps, create, context: card.context, revision: card.revision })
159
+ const detail = target.offer({ key: card.key, steps, create, figureId: create ? undefined : card.figureId, context: card.context, revision: card.revision })
133
160
  if (detail.applied) return accepted()
134
161
  if (detail.applied === false) return refused(detail, say)
135
162
 
@@ -148,11 +175,14 @@ export async function runFigureProposal(card: FigureProposalCard, target: Figure
148
175
  modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new
149
176
  figure must not receive steps nobody is waiting for.
150
177
  */
151
- let pending: { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void } | undefined
178
+ type Settle = (result: { applied: boolean; error?: string; code?: string }) => void
179
+ let pending: { steps: FigureProposalStep[]; key?: string; settle: Settle; hold(): void; resume(): void } | undefined
152
180
 
153
181
  export function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {
154
182
  pending?.settle({ applied: false, code: 'not-opened' })
155
183
  return new Promise(resolve => {
184
+ let timer: ReturnType<typeof setTimeout> | undefined
185
+ const lapse = () => mine.settle({ applied: false, code: 'not-opened' })
156
186
  const mine = {
157
187
  steps,
158
188
  key,
@@ -160,17 +190,36 @@ export function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10
160
190
  clearTimeout(timer)
161
191
  if (pending === mine) pending = undefined
162
192
  resolve(result)
193
+ },
194
+ hold: () => clearTimeout(timer),
195
+ resume: () => {
196
+ clearTimeout(timer)
197
+ timer = setTimeout(lapse, ms)
163
198
  }
164
199
  }
165
- const timer = setTimeout(() => mine.settle({ applied: false, code: 'not-opened' }), ms)
200
+ mine.resume()
166
201
  pending = mine
167
202
  })
168
203
  }
169
204
 
205
+ /**
206
+ * For a page about to ask the person whether to leave unsaved edits: the new figure waiting to open stops its clock
207
+ * while they answer -- an answer is no absent page (figure audit 2026-10-16 N4). Give it the answer: leaving runs the
208
+ * clock again for the page that opens; staying says now that it was not opened. Nothing waiting: undefined.
209
+ */
210
+ export function askingToLeave(): ((leaving: boolean) => void) | undefined {
211
+ const waiting = pending
212
+ if (!waiting) return undefined
213
+ waiting.hold()
214
+ return leaving => {
215
+ if (pending !== waiting) return
216
+ if (leaving) waiting.resume()
217
+ else waiting.settle({ applied: false, code: 'not-opened' })
218
+ }
219
+ }
220
+
170
221
  /** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */
171
- export function takePendingNewFigure():
172
- | { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void }
173
- | undefined {
222
+ export function takePendingNewFigure(): { steps: FigureProposalStep[]; key?: string; settle: Settle } | undefined {
174
223
  const taken = pending
175
224
  pending = undefined
176
225
  return taken
@@ -8,7 +8,6 @@ import { i18next, localize } from '@operato/i18n'
8
8
  import { FIGURE_FACES } from '@hatiolab/figure-model'
9
9
  import type { FigureAuthoringAction, FigureFace } from '@hatiolab/figure-model'
10
10
 
11
- import './figure-carrier.js'
12
11
  import { occupancyFromParts } from './occupancy.js'
13
12
  import { tagsOf, figureFigureFacts, type Placement, type FigureFigureFacts } from './figure-facts.js'
14
13
  import { offeredMountingOf } from './mounting-part.js'
@@ -26,7 +25,7 @@ const FACES = Object.keys(FIGURE_FACES) as FigureFace[]
26
25
  * catalogue files it.
27
26
  *
28
27
  * Two kinds of change leave this tab, and it does neither itself:
29
- * - `command` carries an authoring command (rename, declare the room, the mounting face, the carrier). The page runs it
28
+ * - `command` carries an authoring command (rename, declare the room, the mounting face). The page runs it
30
29
  * through the session, so it is one step of the same undo history as every other edit, and is saved with it.
31
30
  * - `catalog` carries a change to the row (category, tags). Those are not in the asset; the page writes them
32
31
  * to the saved row at once, so they need a saved figure.
@@ -125,7 +124,6 @@ export class FigureProperties extends localize(i18next)(LitElement) {
125
124
  <h3>${i18next.t('figure.label.group-standing')}</h3>
126
125
  ${this.renderSpace(facts)} ${this.renderMounting(facts)}
127
126
  </section>
128
- <figure-carrier .carrier=${this.revision?.asset.carrier}></figure-carrier>
129
127
  <section>
130
128
  <h3>${i18next.t('figure.label.group-catalog')}</h3>
131
129
  ${this.renderCatalog()}
@@ -14,6 +14,15 @@ import {
14
14
  } from '@hatiolab/figure-model'
15
15
 
16
16
  /** Consent to let clips go, and the figure and version it was given on. */
17
+
18
+ /**
19
+ * A request the session refuses, with its code: a screen says it from the code (`sessionRefusalOf`), never from the
20
+ * message, which stays for logs (ADR-0054; figure audit 2026-10-16 N6 -- an AI card put the message in its note).
21
+ */
22
+ function refusal(code: string, message: string): Error & { code: string } {
23
+ return Object.assign(Error(message), { code })
24
+ }
25
+
17
26
  export interface FigureClipConsentFor extends FigureClipConsent {
18
27
  context: string
19
28
  revision: number
@@ -72,8 +81,12 @@ export function createFigureProductSession(store: FigureProductStore) {
72
81
  return result
73
82
  }
74
83
  const inEffect = () => [...new Set(done.filter((tag): tag is string => !!tag))]
84
+ /*
85
+ The creation id is read with the rest: a figure not saved yet is named by it (ADR-0061 decision 3) -- its AI
86
+ conversation is anchored on it, and its first save inserts the row under it, so the name holds across that save.
87
+ */
75
88
  function read() {
76
- return { ...workspace.read(), id, loading, saving, error }
89
+ return { ...workspace.read(), id, creationId, loading, saving, error }
77
90
  }
78
91
  /*
79
92
  The version on screen, one object for as long as it is the version: the workspace hands out one asset per version
@@ -106,13 +119,18 @@ export function createFigureProductSession(store: FigureProductStore) {
106
119
  }
107
120
  return { dropClips: given.dropClips }
108
121
  }
109
- function writable() {
122
+ /* A model open to read: the draft is read during a save too -- the dock reads it when the person sends (audit N5). */
123
+ function readable() {
110
124
  const view = workspace.read()
111
- if (loading || error || !view.context || view.revision === null) throw Error('먼저 모델을 정상적으로 열어 주세요.')
112
- // Serializing writes also makes create retries safe: no second create while the first is pending.
113
- if (saving) throw Error('저장이 끝난 뒤 편집해 주세요.')
125
+ if (loading || error || !view.context || view.revision === null) throw refusal('SESSION_NOT_OPEN', '먼저 모델을 정상적으로 열어 주세요.')
114
126
  return { context: view.context, expectedRevision: view.revision, origin: 'ui' as const }
115
127
  }
128
+ function writable() {
129
+ const open = readable()
130
+ // Serializing writes also makes create retries safe: no second create while the first is pending.
131
+ if (saving) throw refusal('SESSION_SAVING', '저장이 끝난 뒤 편집해 주세요.')
132
+ return open
133
+ }
116
134
  function clear() {
117
135
  workspace.close({ discardDesignChanges: true })
118
136
  id = undefined
@@ -175,7 +193,7 @@ export function createFigureProductSession(store: FigureProductStore) {
175
193
  const control = writable()
176
194
  // AI must supply the context AND revision captured when it started. Never substitute current values.
177
195
  if (origin === 'ai' && (context === undefined || expectedRevision === undefined))
178
- throw Error('AI 편집에는 요청 당시의 모델 문맥과 리비전이 필요합니다.')
196
+ throw refusal('SESSION_VERSION_MISSING', 'AI 편집에는 요청 당시의 모델 문맥과 리비전이 필요합니다.')
179
197
  return tracked('do', () =>
180
198
  workspace.apply({
181
199
  ...control,
@@ -196,13 +214,14 @@ export function createFigureProductSession(store: FigureProductStore) {
196
214
  return tracked('do', () => workspace.applyProposal({ origin: 'ai', context, expectedRevision, steps }), key)
197
215
  },
198
216
  /**
199
- * A new figure made from an AI proposal is created under the proposal's key (ADR-0097 §5.6 item 5): the same
200
- * proposal taken twice meets the first figure on the server, never a second one. Only on a new figure not yet
201
- * touched -- one being made is the person's, and keeps its own.
217
+ * A new figure opened for an AI proposal from another screen is created under the proposal's key (ADR-0097 §5.6
218
+ * item 5). Only before the modeller names the figure to the AI dock: once a conversation is anchored on the creation
219
+ * id it stays (ADR-0061 decision 3), and the save's record of the proposal keeps one proposal one figure. Only on a
220
+ * new figure not yet touched -- one being made is the person's, and keeps its own.
202
221
  */
203
222
  createUnder(key: string) {
204
- if (id || workspace.read().dirty) throw Error('손대지 않은 새 모델에만 쓸 수 있습니다.')
205
- if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key)) throw Error('제안 식별자가 올바르지 않습니다.')
223
+ if (id || workspace.read().dirty) throw refusal('SESSION_NEW_FIGURE_TOUCHED', '손대지 않은 새 모델에만 쓸 수 있습니다.')
224
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(key)) throw refusal('SESSION_PROPOSAL_KEY', '제안 식별자가 올바르지 않습니다.')
206
225
  creationId = key
207
226
  },
208
227
  /** The AI proposals whose steps are in the model now, each once. */
@@ -224,7 +243,7 @@ export function createFigureProductSession(store: FigureProductStore) {
224
243
  /** What a command would do, on a copy (ADR-0097 §5.5): nothing is written, and the answer names its version. */
225
244
  previewCommand(action: FigureAuthoringAction) {
226
245
  const view = workspace.read()
227
- if (loading || error || !view.context || view.revision === null) throw Error('먼저 모델을 정상적으로 열어 주세요.')
246
+ if (loading || error || !view.context || view.revision === null) throw refusal('SESSION_NOT_OPEN', '먼저 모델을 정상적으로 열어 주세요.')
228
247
  return workspace.previewCommand({ context: view.context, action })
229
248
  },
230
249
  /** One authoring command — what a person means, not a graph edit — as one step of the same history. */
@@ -251,7 +270,7 @@ export function createFigureProductSession(store: FigureProductStore) {
251
270
  return tracked('redo', () => workspace.redo(writable()))
252
271
  },
253
272
  source() {
254
- const { context } = writable()
273
+ const { context } = readable()
255
274
  return workspace.export({ context }).text
256
275
  },
257
276
  /**
@@ -226,8 +226,22 @@ function documentLimitRefusalOf(error: unknown, say: Say): string | null {
226
226
  * the kernel's English on the screen (the duplicate of a row's part showed 「EDIT_TARGET: … not a repeated template」,
227
227
  * 2026-09-27).
228
228
  */
229
+ /** The modeller session's own refusals (`product-session.ts`), by their code. Nothing for any other refusal. */
230
+ const SESSION_REFUSALS: Record<string, string> = {
231
+ SESSION_NOT_OPEN: 'figure.text.session-not-open',
232
+ SESSION_SAVING: 'figure.text.session-saving',
233
+ SESSION_VERSION_MISSING: 'figure.text.session-version-missing',
234
+ SESSION_NEW_FIGURE_TOUCHED: 'figure.text.session-new-figure-touched',
235
+ SESSION_PROPOSAL_KEY: 'figure.text.session-proposal-key'
236
+ }
237
+ export function sessionRefusalOf(error: unknown, say: Say): string | null {
238
+ const key = SESSION_REFUSALS[codeOf(error) ?? '']
239
+ return key ? say(key, {}) : null
240
+ }
241
+
229
242
  export function editRefusalOf(error: unknown, say: Say): string {
230
243
  return (
244
+ sessionRefusalOf(error, say) ??
231
245
  sizeRangeRefusalOf(error, say) ??
232
246
  inputRangeRefusalOf(error, say) ??
233
247
  repeatPitchRefusalOf(error, say) ??
@@ -6,7 +6,8 @@ import { modellerPathOf } from '../modeller/modeller-route.js'
6
6
  import { createFigureProductSession } from '../modeller/product-session.js'
7
7
  import { figureProductStore } from '../graphql/store.js'
8
8
  import { i18next } from '@operato/i18n'
9
- import { fetchFigure, fetchFigureTypeNames, fetchProposalOutcome, updateFigure } from '../graphql/index.js'
9
+ import { fetchFigure, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
10
+ import { figureProposalOutcome } from '../figure-assistant.js'
10
11
  import { availableRecipes, recipeGuide } from '../modeller/recipe-source.js'
11
12
  import type { RecipeTemplate } from '../modeller/recipes.js'
12
13
  import { occupancyFromParts } from '../modeller/occupancy.js'
@@ -30,7 +31,7 @@ import {
30
31
  import type { FirstSave } from '../modeller/first-save.js'
31
32
  import { typeOf } from '../modeller/first-save.js'
32
33
  import { editRefusalOf } from '../modeller/refusals.js'
33
- import { proposalOutcomeFrom, takeFigureOffer, takePendingNewFigure } from '../modeller/ai-proposal.js'
34
+ import { askingToLeave, takeFigureOffer, takePendingNewFigure } from '../modeller/ai-proposal.js'
34
35
 
35
36
  /**
36
37
  * The product page has exactly one authoritative workspace for UI and AI input transactions.
@@ -126,22 +127,23 @@ export class FigureModellerPage extends PageView {
126
127
  /** The figure the AI dock was last told about - another id is another conversation. */
127
128
  private assistantFor?: string
128
129
  /*
129
- The figure on screen is what the AI dock talks about (design/ai/shared-dock.md §3). A figure not saved yet has no
130
- row to hang a conversation on, so it provides nothing and the dock stays on the application's conversation.
130
+ The figure on screen is what the AI dock talks about (design/ai/shared-dock.md §3). A figure not saved yet is named
131
+ by its creation id, the id its first save inserts the row under (ADR-0061 decision 3, ADR-0072 decision 6): the
132
+ anchor is the same before and after that save, so the conversation goes on, with nothing to bind again.
131
133
  The draft is read at the moment of sending, never cached - the dock must not answer about a shape already changed.
132
134
  */
133
135
  private provideAssistant = () => {
134
136
  const view = this.session.read()
135
137
  if (!this.active || view.loading || view.error || !view.context) return
136
- if (!view.id) {
138
+ const id = view.id ?? view.creationId
139
+ if (!id) {
137
140
  this.withdrawAssistant()
138
141
  return
139
142
  }
140
- if (this.assistantFor === view.id) {
143
+ if (this.assistantFor === id) {
141
144
  notifyAssistantSurface(this)
142
145
  return
143
146
  }
144
- const id = view.id
145
147
  this.assistantFor = id
146
148
  provideAssistantSurface(this, {
147
149
  key: `figure:${id}`,
@@ -151,11 +153,10 @@ export class FigureModellerPage extends PageView {
151
153
  detail: () => (this.session.read().dirty ? i18next.t('figure.text.unsaved-changes') : ''),
152
154
  anchor: () => ({ anchorType: 'figure', anchorId: id }),
153
155
  /* The card's "check the result": the server's record, never the dock's memory; nothing is applied again. */
154
- proposalOutcome: async (proposalKey: string) =>
155
- proposalOutcomeFrom(await fetchProposalOutcome(proposalKey), (key, params) => i18next.t(key, params)),
156
+ proposalOutcome: figureProposalOutcome,
156
157
  hostContext: () => ({
157
158
  surface: 'figure-modeller',
158
- editorMode: 'existing',
159
+ editorMode: this.session.read().id ? 'existing' : 'new',
159
160
  figureId: id,
160
161
  draftSource: JSON.parse(this.session.source()),
161
162
  /* The version the model sees; a proposal is applied on it, or refused if the figure moved on (ADR-0094). */
@@ -388,6 +389,8 @@ export class FigureModellerPage extends PageView {
388
389
  The shell asks this while the edited model is still on screen. The app's own prompt, as every operato screen
389
390
  asks -- not the browser's confirm box (user, 2026-09-24).
390
391
  */
392
+ /* A new figure the dock is opening waits for this answer, not against a clock (audit N4). */
393
+ const answered = askingToLeave()
391
394
  this.discardApproved = !!(await OxPrompt.open({
392
395
  type: 'warning',
393
396
  title: i18next.t('figure.text.unsaved-changes'),
@@ -395,6 +398,7 @@ export class FigureModellerPage extends PageView {
395
398
  confirmButton: { text: i18next.t('figure.button.leave-without-saving') },
396
399
  cancelButton: { text: i18next.t('figure.button.keep-editing') }
397
400
  }))
401
+ answered?.(this.discardApproved)
398
402
  return this.discardApproved
399
403
  }
400
404
  async pageUpdated(changes: Record<string, unknown>, lifecycle: { resourceId?: string }) {
@@ -1 +1,7 @@
1
1
  export declare const FIGURE_CANDIDATE_TOOL = "proposeFigureCandidate";
2
+ /**
3
+ * The dock's "check the result" of a figure proposal, on every screen that offers its card: what the server recorded of
4
+ * its save (ADR-0097 §5.6 item 5). The modeller asks it, and so does an app's home where a new figure's card is made --
5
+ * there it was missing, and a card cut off by a reload stayed "no answer" for good (figure audit 2026-10-16 N8).
6
+ */
7
+ export declare function figureProposalOutcome(proposalKey: string): Promise<import("./modeller/ai-proposal.js").FigureProposalOutcome | undefined>;
@@ -9,9 +9,18 @@
9
9
  import { i18next } from '@operato/i18n';
10
10
  import { navigate } from '@operato/shell';
11
11
  import { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js';
12
+ import { fetchProposalOutcome } from './graphql/index.js';
12
13
  import { modellerPathOf } from './modeller/modeller-route.js';
13
- import { pendNewFigure, runFigureProposal } from './modeller/ai-proposal.js';
14
+ import { pendNewFigure, proposalOutcomeFrom, runFigureProposal } from './modeller/ai-proposal.js';
14
15
  export const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate';
16
+ /**
17
+ * The dock's "check the result" of a figure proposal, on every screen that offers its card: what the server recorded of
18
+ * its save (ADR-0097 §5.6 item 5). The modeller asks it, and so does an app's home where a new figure's card is made --
19
+ * there it was missing, and a card cut off by a reload stayed "no answer" for good (figure audit 2026-10-16 N8).
20
+ */
21
+ export async function figureProposalOutcome(proposalKey) {
22
+ return proposalOutcomeFrom(await fetchProposalOutcome(proposalKey), (key, params) => i18next.t(key, params));
23
+ }
15
24
  registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
16
25
  const outcome = await runFigureProposal(proposal, {
17
26
  offer(detail) {
@@ -22,6 +31,10 @@ registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
22
31
  const opened = pendNewFigure(steps, key);
23
32
  navigate(modellerPathOf());
24
33
  return opened;
34
+ },
35
+ savedInto: fetchProposalOutcome,
36
+ openSaved(id) {
37
+ navigate(modellerPathOf({ id }));
25
38
  }
26
39
  }, (key, params) => i18next.t(key, params));
27
40
  if (outcome.note)
@@ -1 +1 @@
1
- {"version":3,"file":"figure-assistant.js","sourceRoot":"","sources":["../client/figure-assistant.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,mDAAmD,CAAA;AAE3F,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAA;AAC7D,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAA0B,MAAM,2BAA2B,CAAA;AAEpG,MAAM,CAAC,MAAM,qBAAqB,GAAG,wBAAwB,CAAA;AAE7D,uBAAuB,CAAC,qBAAqB,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE;IACzE,MAAM,OAAO,GAAG,MAAM,iBAAiB,CACrC,QAAQ,EACR;QACE,KAAK,CAAC,MAAyB;YAC7B,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,cAAc,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC,CAAA;YACjE,OAAO,MAAM,CAAA;QACf,CAAC;QACD,OAAO,CAAC,KAAK,EAAE,GAAG;YAChB,MAAM,MAAM,GAAG,aAAa,CAAC,KAAK,EAAE,GAAG,CAAC,CAAA;YACxC,QAAQ,CAAC,cAAc,EAAE,CAAC,CAAA;YAC1B,OAAO,MAAM,CAAA;QACf,CAAC;KACF,EACD,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,CACxC,CAAA;IACD,IAAI,OAAO,CAAC,IAAI;QAAE,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,KAAK,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;IACjG,OAAO,OAAO,CAAA;AAChB,CAAC,CAAC,CAAA","sourcesContent":["/*\n * What the AI dock does with a figure proposal - figure-ui declares it because figure-service declares the tool\n * (`proposeFigureCandidate`, design/ai/shared-dock.md §4).\n *\n * A proposal is a list of the modeller's own commands (ADR-0094). Running it applies them in the modeller as one step\n * of its history: a revision on the figure open there, at the version the model saw; a new figure on a new figure the\n * modeller opens. The outcome is said in words on every branch (`ai-proposal.ts`).\n */\nimport { i18next } from '@operato/i18n'\nimport { navigate } from '@operato/shell'\nimport { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js'\n\nimport { modellerPathOf } from './modeller/modeller-route.js'\nimport { pendNewFigure, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'\n\nexport const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'\n\nregisterProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {\n const outcome = await runFigureProposal(\n proposal,\n {\n offer(detail: FigureApplyDetail) {\n window.dispatchEvent(new CustomEvent('figure-apply', { detail }))\n return detail\n },\n openNew(steps, key) {\n const opened = pendNewFigure(steps, key)\n navigate(modellerPathOf())\n return opened\n }\n },\n (key, params) => i18next.t(key, params)\n )\n if (outcome.note) context.notify(outcome.verdict === 'accepted' ? 'info' : 'error', outcome.note)\n return outcome\n})\n"]}
1
+ {"version":3,"file":"figure-assistant.js","sourceRoot":"","sources":["../client/figure-assistant.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,mDAAmD,CAAA;AAE3F,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAA;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAA;AAC7D,OAAO,EAAE,aAAa,EAAE,mBAAmB,EAAE,iBAAiB,EAA0B,MAAM,2BAA2B,CAAA;AAEzH,MAAM,CAAC,MAAM,qBAAqB,GAAG,wBAAwB,CAAA;AAE7D;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,WAAmB;IAC7D,OAAO,mBAAmB,CAAC,MAAM,oBAAoB,CAAC,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,CAAA;AAC9G,CAAC;AAED,uBAAuB,CAAC,qBAAqB,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE;IACzE,MAAM,OAAO,GAAG,MAAM,iBAAiB,CACrC,QAAQ,EACR;QACE,KAAK,CAAC,MAAyB;YAC7B,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,cAAc,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC,CAAA;YACjE,OAAO,MAAM,CAAA;QACf,CAAC;QACD,OAAO,CAAC,KAAK,EAAE,GAAG;YAChB,MAAM,MAAM,GAAG,aAAa,CAAC,KAAK,EAAE,GAAG,CAAC,CAAA;YACxC,QAAQ,CAAC,cAAc,EAAE,CAAC,CAAA;YAC1B,OAAO,MAAM,CAAA;QACf,CAAC;QACD,SAAS,EAAE,oBAAoB;QAC/B,SAAS,CAAC,EAAE;YACV,QAAQ,CAAC,cAAc,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAA;QAClC,CAAC;KACF,EACD,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,CACxC,CAAA;IACD,IAAI,OAAO,CAAC,IAAI;QAAE,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,KAAK,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;IACjG,OAAO,OAAO,CAAA;AAChB,CAAC,CAAC,CAAA","sourcesContent":["/*\n * What the AI dock does with a figure proposal - figure-ui declares it because figure-service declares the tool\n * (`proposeFigureCandidate`, design/ai/shared-dock.md §4).\n *\n * A proposal is a list of the modeller's own commands (ADR-0094). Running it applies them in the modeller as one step\n * of its history: a revision on the figure open there, at the version the model saw; a new figure on a new figure the\n * modeller opens. The outcome is said in words on every branch (`ai-proposal.ts`).\n */\nimport { i18next } from '@operato/i18n'\nimport { navigate } from '@operato/shell'\nimport { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js'\n\nimport { fetchProposalOutcome } from './graphql/index.js'\nimport { modellerPathOf } from './modeller/modeller-route.js'\nimport { pendNewFigure, proposalOutcomeFrom, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'\n\nexport const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'\n\n/**\n * The dock's \"check the result\" of a figure proposal, on every screen that offers its card: what the server recorded of\n * its save (ADR-0097 §5.6 item 5). The modeller asks it, and so does an app's home where a new figure's card is made --\n * there it was missing, and a card cut off by a reload stayed \"no answer\" for good (figure audit 2026-10-16 N8).\n */\nexport async function figureProposalOutcome(proposalKey: string) {\n return proposalOutcomeFrom(await fetchProposalOutcome(proposalKey), (key, params) => i18next.t(key, params))\n}\n\nregisterProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {\n const outcome = await runFigureProposal(\n proposal,\n {\n offer(detail: FigureApplyDetail) {\n window.dispatchEvent(new CustomEvent('figure-apply', { detail }))\n return detail\n },\n openNew(steps, key) {\n const opened = pendNewFigure(steps, key)\n navigate(modellerPathOf())\n return opened\n },\n savedInto: fetchProposalOutcome,\n openSaved(id) {\n navigate(modellerPathOf({ id }))\n }\n },\n (key, params) => i18next.t(key, params)\n )\n if (outcome.note) context.notify(outcome.verdict === 'accepted' ? 'info' : 'error', outcome.note)\n return outcome\n})\n"]}
@@ -19,6 +19,8 @@ export interface FigureApplyDetail extends FigureRefusalRecord {
19
19
  key?: string;
20
20
  steps: FigureProposalStep[];
21
21
  create: boolean;
22
+ /** The figure a revision was made for: the one the model read. */
23
+ figureId?: string;
22
24
  context?: string;
23
25
  revision?: number;
24
26
  applied?: boolean;
@@ -34,6 +36,14 @@ export interface FigureProposalTarget {
34
36
  error?: string;
35
37
  code?: string;
36
38
  }>;
39
+ /** The figures the server recorded a save of this proposal into (`figureProposalOutcome`), first saved first. */
40
+ savedInto(key: string): Promise<ReadonlyArray<{
41
+ figureId: string;
42
+ type?: string;
43
+ name?: string;
44
+ }>>;
45
+ /** Opens a saved figure in the modeller. */
46
+ openSaved(figureId: string): void;
37
47
  }
38
48
  export interface FigureProposalOutcome {
39
49
  verdict: 'accepted' | 'refused' | 'unavailable';
@@ -64,18 +74,26 @@ export declare function takeFigureOffer(session: FigureProductSession, detail: F
64
74
  say: Say;
65
75
  }): boolean;
66
76
  export declare function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome>;
77
+ type Settle = (result: {
78
+ applied: boolean;
79
+ error?: string;
80
+ code?: string;
81
+ }) => void;
67
82
  export declare function pendNewFigure(steps: FigureProposalStep[], key?: string, ms?: number): Promise<{
68
83
  applied: boolean;
69
84
  error?: string;
70
85
  code?: string;
71
86
  }>;
87
+ /**
88
+ * For a page about to ask the person whether to leave unsaved edits: the new figure waiting to open stops its clock
89
+ * while they answer -- an answer is no absent page (figure audit 2026-10-16 N4). Give it the answer: leaving runs the
90
+ * clock again for the page that opens; staying says now that it was not opened. Nothing waiting: undefined.
91
+ */
92
+ export declare function askingToLeave(): ((leaving: boolean) => void) | undefined;
72
93
  /** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */
73
94
  export declare function takePendingNewFigure(): {
74
95
  steps: FigureProposalStep[];
75
96
  key?: string;
76
- settle: (result: {
77
- applied: boolean;
78
- error?: string;
79
- code?: string;
80
- }) => void;
97
+ settle: Settle;
81
98
  } | undefined;
99
+ export {};