@things-factory/figure-ui 10.1.168 → 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.
@@ -12,10 +12,19 @@ import { registerProposalHandler } from '@things-factory/ai-assistant/dist-clien
12
12
 
13
13
  import { fetchProposalOutcome } from './graphql/index.js'
14
14
  import { modellerPathOf } from './modeller/modeller-route.js'
15
- import { pendNewFigure, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'
15
+ import { pendNewFigure, proposalOutcomeFrom, runFigureProposal, type FigureApplyDetail } from './modeller/ai-proposal.js'
16
16
 
17
17
  export const FIGURE_CANDIDATE_TOOL = 'proposeFigureCandidate'
18
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
+
19
28
  registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
20
29
  const outcome = await runFigureProposal(
21
30
  proposal,
@@ -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
@@ -82,7 +84,8 @@ function refused(refusal: FigureRefusalRecord & { error?: string }, say: Say): F
82
84
  ? (error ?? '')
83
85
  : clips
84
86
  ? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })
85
- : 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 ?? '') })
86
89
  return { verdict: 'refused', errorCode: code ?? 'refused', note }
87
90
  }
88
91
 
@@ -99,6 +102,12 @@ export function takeFigureOffer(
99
102
  ): boolean {
100
103
  const view = session.read()
101
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
102
111
  if (detail.create && view.dirty) {
103
112
  /* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */
104
113
  detail.applied = false
@@ -128,7 +137,7 @@ export function takeFigureOffer(
128
137
 
129
138
  export async function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome> {
130
139
  const steps = card?.steps
131
- 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') }
132
141
  const create = card.create === true
133
142
  /*
134
143
  A new figure's proposal already saved into a figure is not made again: that figure is opened (ADR-0061 decision 3,
@@ -147,7 +156,7 @@ export async function runFigureProposal(card: FigureProposalCard, target: Figure
147
156
  note: say('figure.text.proposal-applied', { n: String(steps.length) })
148
157
  })
149
158
 
150
- 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 })
151
160
  if (detail.applied) return accepted()
152
161
  if (detail.applied === false) return refused(detail, say)
153
162
 
@@ -166,11 +175,14 @@ export async function runFigureProposal(card: FigureProposalCard, target: Figure
166
175
  modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new
167
176
  figure must not receive steps nobody is waiting for.
168
177
  */
169
- 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
170
180
 
171
181
  export function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {
172
182
  pending?.settle({ applied: false, code: 'not-opened' })
173
183
  return new Promise(resolve => {
184
+ let timer: ReturnType<typeof setTimeout> | undefined
185
+ const lapse = () => mine.settle({ applied: false, code: 'not-opened' })
174
186
  const mine = {
175
187
  steps,
176
188
  key,
@@ -178,17 +190,36 @@ export function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10
178
190
  clearTimeout(timer)
179
191
  if (pending === mine) pending = undefined
180
192
  resolve(result)
193
+ },
194
+ hold: () => clearTimeout(timer),
195
+ resume: () => {
196
+ clearTimeout(timer)
197
+ timer = setTimeout(lapse, ms)
181
198
  }
182
199
  }
183
- const timer = setTimeout(() => mine.settle({ applied: false, code: 'not-opened' }), ms)
200
+ mine.resume()
184
201
  pending = mine
185
202
  })
186
203
  }
187
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
+
188
221
  /** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */
189
- export function takePendingNewFigure():
190
- | { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void }
191
- | undefined {
222
+ export function takePendingNewFigure(): { steps: FigureProposalStep[]; key?: string; settle: Settle } | undefined {
192
223
  const taken = pending
193
224
  pending = undefined
194
225
  return taken
@@ -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
@@ -110,13 +119,18 @@ export function createFigureProductSession(store: FigureProductStore) {
110
119
  }
111
120
  return { dropClips: given.dropClips }
112
121
  }
113
- 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() {
114
124
  const view = workspace.read()
115
- if (loading || error || !view.context || view.revision === null) throw Error('먼저 모델을 정상적으로 열어 주세요.')
116
- // Serializing writes also makes create retries safe: no second create while the first is pending.
117
- if (saving) throw Error('저장이 끝난 뒤 편집해 주세요.')
125
+ if (loading || error || !view.context || view.revision === null) throw refusal('SESSION_NOT_OPEN', '먼저 모델을 정상적으로 열어 주세요.')
118
126
  return { context: view.context, expectedRevision: view.revision, origin: 'ui' as const }
119
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
+ }
120
134
  function clear() {
121
135
  workspace.close({ discardDesignChanges: true })
122
136
  id = undefined
@@ -179,7 +193,7 @@ export function createFigureProductSession(store: FigureProductStore) {
179
193
  const control = writable()
180
194
  // AI must supply the context AND revision captured when it started. Never substitute current values.
181
195
  if (origin === 'ai' && (context === undefined || expectedRevision === undefined))
182
- throw Error('AI 편집에는 요청 당시의 모델 문맥과 리비전이 필요합니다.')
196
+ throw refusal('SESSION_VERSION_MISSING', 'AI 편집에는 요청 당시의 모델 문맥과 리비전이 필요합니다.')
183
197
  return tracked('do', () =>
184
198
  workspace.apply({
185
199
  ...control,
@@ -206,8 +220,8 @@ export function createFigureProductSession(store: FigureProductStore) {
206
220
  * new figure not yet touched -- one being made is the person's, and keeps its own.
207
221
  */
208
222
  createUnder(key: string) {
209
- if (id || workspace.read().dirty) throw Error('손대지 않은 새 모델에만 쓸 수 있습니다.')
210
- 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', '제안 식별자가 올바르지 않습니다.')
211
225
  creationId = key
212
226
  },
213
227
  /** The AI proposals whose steps are in the model now, each once. */
@@ -229,7 +243,7 @@ export function createFigureProductSession(store: FigureProductStore) {
229
243
  /** What a command would do, on a copy (ADR-0097 §5.5): nothing is written, and the answer names its version. */
230
244
  previewCommand(action: FigureAuthoringAction) {
231
245
  const view = workspace.read()
232
- if (loading || error || !view.context || view.revision === null) throw Error('먼저 모델을 정상적으로 열어 주세요.')
246
+ if (loading || error || !view.context || view.revision === null) throw refusal('SESSION_NOT_OPEN', '먼저 모델을 정상적으로 열어 주세요.')
233
247
  return workspace.previewCommand({ context: view.context, action })
234
248
  },
235
249
  /** One authoring command — what a person means, not a graph edit — as one step of the same history. */
@@ -256,7 +270,7 @@ export function createFigureProductSession(store: FigureProductStore) {
256
270
  return tracked('redo', () => workspace.redo(writable()))
257
271
  },
258
272
  source() {
259
- const { context } = writable()
273
+ const { context } = readable()
260
274
  return workspace.export({ context }).text
261
275
  },
262
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.
@@ -152,8 +153,7 @@ export class FigureModellerPage extends PageView {
152
153
  detail: () => (this.session.read().dirty ? i18next.t('figure.text.unsaved-changes') : ''),
153
154
  anchor: () => ({ anchorType: 'figure', anchorId: id }),
154
155
  /* The card's "check the result": the server's record, never the dock's memory; nothing is applied again. */
155
- proposalOutcome: async (proposalKey: string) =>
156
- proposalOutcomeFrom(await fetchProposalOutcome(proposalKey), (key, params) => i18next.t(key, params)),
156
+ proposalOutcome: figureProposalOutcome,
157
157
  hostContext: () => ({
158
158
  surface: 'figure-modeller',
159
159
  editorMode: this.session.read().id ? 'existing' : 'new',
@@ -389,6 +389,8 @@ export class FigureModellerPage extends PageView {
389
389
  The shell asks this while the edited model is still on screen. The app's own prompt, as every operato screen
390
390
  asks -- not the browser's confirm box (user, 2026-09-24).
391
391
  */
392
+ /* A new figure the dock is opening waits for this answer, not against a clock (audit N4). */
393
+ const answered = askingToLeave()
392
394
  this.discardApproved = !!(await OxPrompt.open({
393
395
  type: 'warning',
394
396
  title: i18next.t('figure.text.unsaved-changes'),
@@ -396,6 +398,7 @@ export class FigureModellerPage extends PageView {
396
398
  confirmButton: { text: i18next.t('figure.button.leave-without-saving') },
397
399
  cancelButton: { text: i18next.t('figure.button.keep-editing') }
398
400
  }))
401
+ answered?.(this.discardApproved)
399
402
  return this.discardApproved
400
403
  }
401
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>;
@@ -11,8 +11,16 @@ import { navigate } from '@operato/shell';
11
11
  import { registerProposalHandler } from '@things-factory/ai-assistant/dist-client/index.js';
12
12
  import { fetchProposalOutcome } from './graphql/index.js';
13
13
  import { modellerPathOf } from './modeller/modeller-route.js';
14
- import { pendNewFigure, runFigureProposal } from './modeller/ai-proposal.js';
14
+ import { pendNewFigure, proposalOutcomeFrom, runFigureProposal } from './modeller/ai-proposal.js';
15
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
+ }
16
24
  registerProposalHandler(FIGURE_CANDIDATE_TOOL, async (proposal, context) => {
17
25
  const outcome = await runFigureProposal(proposal, {
18
26
  offer(detail) {
@@ -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,oBAAoB,EAAE,MAAM,oBAAoB,CAAA;AACzD,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;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, 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 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"]}
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;
@@ -72,18 +74,26 @@ export declare function takeFigureOffer(session: FigureProductSession, detail: F
72
74
  say: Say;
73
75
  }): boolean;
74
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;
75
82
  export declare function pendNewFigure(steps: FigureProposalStep[], key?: string, ms?: number): Promise<{
76
83
  applied: boolean;
77
84
  error?: string;
78
85
  code?: string;
79
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;
80
93
  /** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */
81
94
  export declare function takePendingNewFigure(): {
82
95
  steps: FigureProposalStep[];
83
96
  key?: string;
84
- settle: (result: {
85
- applied: boolean;
86
- error?: string;
87
- code?: string;
88
- }) => void;
97
+ settle: Settle;
89
98
  } | undefined;
99
+ export {};
@@ -1,4 +1,4 @@
1
- import { clipLossOf, refusalRecordOf } from './refusals.js';
1
+ import { clipLossOf, editRefusalOf, refusalRecordOf } from './refusals.js';
2
2
  /**
3
3
  * The dock's "check the result" (ADR-0097 §5.6 item 5): what the server recorded of a proposal. Saved into figures:
4
4
  * accepted, named. Nothing recorded: not known -- applied and not saved yet, undone, or never taken -- so `undefined`,
@@ -25,7 +25,8 @@ function refused(refusal, say) {
25
25
  ? (error ?? '')
26
26
  : clips
27
27
  ? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })
28
- : say('figure.text.proposal-refused', { reason: error ?? '' });
28
+ : /* A refusal with a code is said from it, as the modeller says an edit it refuses; never its message (audit N6). */
29
+ say('figure.text.proposal-refused', { reason: code ? editRefusalOf(refusal, (key, params) => say(key, params)) : (error ?? '') });
29
30
  return { verdict: 'refused', errorCode: code ?? 'refused', note };
30
31
  }
31
32
  /**
@@ -38,6 +39,13 @@ export function takeFigureOffer(session, detail, { onSavedFigure, say }) {
38
39
  const view = session.read();
39
40
  if (detail.create && onSavedFigure)
40
41
  return false;
42
+ /*
43
+ A revision is for the figure the model read; with another open it is left untaken, and the handler says it needs that
44
+ figure open. The version is no proof of the figure: two figures opened in two page loads stood at the same one
45
+ (figure audit 2026-10-16 N3).
46
+ */
47
+ if (!detail.create && typeof detail.figureId === 'string' && detail.figureId !== (view.id ?? view.creationId))
48
+ return false;
41
49
  if (detail.create && view.dirty) {
42
50
  /* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */
43
51
  detail.applied = false;
@@ -71,7 +79,7 @@ export function takeFigureOffer(session, detail, { onSavedFigure, say }) {
71
79
  export async function runFigureProposal(card, target, say) {
72
80
  const steps = card?.steps;
73
81
  if (!Array.isArray(steps) || !steps.length)
74
- return { verdict: 'unavailable', errorCode: 'no-steps' };
82
+ return { verdict: 'unavailable', errorCode: 'no-steps', note: say('figure.text.proposal-empty') };
75
83
  const create = card.create === true;
76
84
  /*
77
85
  A new figure's proposal already saved into a figure is not made again: that figure is opened (ADR-0061 decision 3,
@@ -89,7 +97,7 @@ export async function runFigureProposal(card, target, say) {
89
97
  applied: steps.length,
90
98
  note: say('figure.text.proposal-applied', { n: String(steps.length) })
91
99
  });
92
- const detail = target.offer({ key: card.key, steps, create, context: card.context, revision: card.revision });
100
+ const detail = target.offer({ key: card.key, steps, create, figureId: create ? undefined : card.figureId, context: card.context, revision: card.revision });
93
101
  if (detail.applied)
94
102
  return accepted();
95
103
  if (detail.applied === false)
@@ -104,15 +112,12 @@ export async function runFigureProposal(card, target, say) {
104
112
  ? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }
105
113
  : refused(opened, say);
106
114
  }
107
- /*
108
- A new figure's proposal handed to the modeller page across a navigation: the handler leaves it here and opens the
109
- modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new
110
- figure must not receive steps nobody is waiting for.
111
- */
112
115
  let pending;
113
116
  export function pendNewFigure(steps, key, ms = 10_000) {
114
117
  pending?.settle({ applied: false, code: 'not-opened' });
115
118
  return new Promise(resolve => {
119
+ let timer;
120
+ const lapse = () => mine.settle({ applied: false, code: 'not-opened' });
116
121
  const mine = {
117
122
  steps,
118
123
  key,
@@ -121,12 +126,36 @@ export function pendNewFigure(steps, key, ms = 10_000) {
121
126
  if (pending === mine)
122
127
  pending = undefined;
123
128
  resolve(result);
129
+ },
130
+ hold: () => clearTimeout(timer),
131
+ resume: () => {
132
+ clearTimeout(timer);
133
+ timer = setTimeout(lapse, ms);
124
134
  }
125
135
  };
126
- const timer = setTimeout(() => mine.settle({ applied: false, code: 'not-opened' }), ms);
136
+ mine.resume();
127
137
  pending = mine;
128
138
  });
129
139
  }
140
+ /**
141
+ * For a page about to ask the person whether to leave unsaved edits: the new figure waiting to open stops its clock
142
+ * while they answer -- an answer is no absent page (figure audit 2026-10-16 N4). Give it the answer: leaving runs the
143
+ * clock again for the page that opens; staying says now that it was not opened. Nothing waiting: undefined.
144
+ */
145
+ export function askingToLeave() {
146
+ const waiting = pending;
147
+ if (!waiting)
148
+ return undefined;
149
+ waiting.hold();
150
+ return leaving => {
151
+ if (pending !== waiting)
152
+ return;
153
+ if (leaving)
154
+ waiting.resume();
155
+ else
156
+ waiting.settle({ applied: false, code: 'not-opened' });
157
+ };
158
+ }
130
159
  /** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */
131
160
  export function takePendingNewFigure() {
132
161
  const taken = pending;
@@ -1 +1 @@
1
- {"version":3,"file":"ai-proposal.js","sourceRoot":"","sources":["../../client/modeller/ai-proposal.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * An AI proposal reaching the modeller (ADR-0094). The dock's card carries the modeller's own commands; running it\n * applies them as one step of the modeller's history, undone whole, on the version the model saw. Nothing half made\n * is applied, and every outcome is said: applied, refused with the reason, or not possible here.\n *\n * Kept free of Lit, i18n and the shell so node tests read it directly; the handler and the page pass in what they own.\n */\nimport type { FigureProposalStep } from '@hatiolab/figure-model'\nimport type { FigureProductSession } from './product-session.js'\nimport { clipLossOf, refusalRecordOf, type FigureRefusalRecord } from './refusals.js'\n\n/** What the card carries, as `proposeFigureCandidate` returned it. */\nexport interface FigureProposalCard {\n /** The proposal's own name, given by the tool: a save records it, a new figure made from it is created under it. */\n key?: string\n steps?: FigureProposalStep[]\n create?: boolean\n figureId?: string\n context?: string\n revision?: number\n}\n\n/**\n * The `figure-apply` event's detail. The page that takes it sets `applied`, and when refused `error` and the refusal in\n * figure-model's shape (`code`, `details`, the `clips` it would stop).\n */\nexport interface FigureApplyDetail extends FigureRefusalRecord {\n key?: string\n steps: FigureProposalStep[]\n create: boolean\n context?: string\n revision?: number\n applied?: boolean\n error?: string\n}\n\nexport type Say = (key: string, params?: Record<string, string>) => string\n\nexport interface FigureProposalTarget {\n /** Offers the proposal to a modeller on screen. It comes back untouched when none took it. */\n offer(detail: FigureApplyDetail): FigureApplyDetail\n /** Opens a new figure in the modeller and applies the steps there; resolves once the page has, or has not. */\n openNew(steps: FigureProposalStep[], key?: string): Promise<{ applied: boolean; error?: string; code?: string }>\n /** The figures the server recorded a save of this proposal into (`figureProposalOutcome`), first saved first. */\n savedInto(key: string): Promise<ReadonlyArray<{ figureId: string; type?: string; name?: string }>>\n /** Opens a saved figure in the modeller. */\n openSaved(figureId: string): void\n}\n\nexport interface FigureProposalOutcome {\n verdict: 'accepted' | 'refused' | 'unavailable'\n errorCode?: string\n applied?: number\n note?: string\n}\n\n/**\n * The dock's \"check the result\" (ADR-0097 §5.6 item 5): what the server recorded of a proposal. Saved into figures:\n * accepted, named. Nothing recorded: not known -- applied and not saved yet, undone, or never taken -- so `undefined`,\n * which the dock keeps as \"not known\" and never turns into a refusal. Nothing is run again.\n */\nexport function proposalOutcomeFrom(saved: ReadonlyArray<{ figureId: string; type?: string; name?: string }>, say: Say): FigureProposalOutcome | undefined {\n if (!saved.length) return undefined\n const names = saved.map(f => f.name || f.type || f.figureId).join(', ')\n return { verdict: 'accepted', note: say('figure.text.proposal-saved-into', { names }) }\n}\n\n/** The workspace refuses a request made on another version; said as what happened, not as the kernel's code. */\nconst STALE = new Set(['REVISION_CONFLICT', 'CONTEXT_CONFLICT'])\n\nfunction refused(refusal: FigureRefusalRecord & { error?: string }, say: Say): FigureProposalOutcome {\n const { code, error } = refusal\n const clips = clipLossOf(refusal)\n /*\n A proposal that would stop clips is refused whole; nobody but the person may agree to that, and a proposal is not\n asked about -- the person edits by hand if they mean it (designer 2026-09-27).\n */\n const note =\n code && STALE.has(code)\n ? say('figure.text.proposal-stale')\n : code === 'NEW_FIGURE_UNSAVED'\n ? (error ?? '')\n : clips\n ? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })\n : say('figure.text.proposal-refused', { reason: error ?? '' })\n return { verdict: 'refused', errorCode: code ?? 'refused', note }\n}\n\n/**\n * The modeller page's way of taking an offer from the dock (`figure-apply`): input changes, or a proposal's commands\n * as one step (ADR-0094). A revision is applied on the version the model saw; a new figure's proposal only on a new\n * figure not yet touched, and a page on a saved figure leaves it untaken. Never with a consent to let clips go: a\n * proposal that would stop one is refused whole, the clips named (designer 2026-09-27). True when it was applied.\n */\nexport function takeFigureOffer(\n session: FigureProductSession,\n detail: FigureApplyDetail & { changes?: unknown },\n { onSavedFigure, say }: { onSavedFigure: boolean; say: Say }\n): boolean {\n const view = session.read()\n if (detail.create && onSavedFigure) return false\n if (detail.create && view.dirty) {\n /* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */\n detail.applied = false\n detail.code = 'NEW_FIGURE_UNSAVED'\n detail.error = say('figure.text.proposal-new-figure-unsaved')\n return false\n }\n try {\n if (detail.create) {\n /*\n Taken on the new figure the modeller has open, whose conversation asked for it: the figure keeps its own creation\n id, the anchor of that conversation (ADR-0061 decision 3, ruled 2026-10-16). One proposal stays one figure by the\n save's record of it, which `runFigureProposal` reads before the card runs again.\n */\n session.applyProposal(detail.steps, view.revision!, view.context!, detail.key)\n } else if (detail.steps) session.applyProposal(detail.steps, detail.revision!, detail.context!, detail.key)\n else session.apply(detail.changes as never, 'ai', detail.revision, detail.context)\n detail.applied = true\n return true\n } catch (error) {\n detail.applied = false\n Object.assign(detail, refusalRecordOf(error))\n detail.error = error instanceof Error ? error.message : String(error)\n return false\n }\n}\n\nexport async function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome> {\n const steps = card?.steps\n if (!Array.isArray(steps) || !steps.length) return { verdict: 'unavailable', errorCode: 'no-steps' }\n const create = card.create === true\n /*\n A new figure's proposal already saved into a figure is not made again: that figure is opened (ADR-0061 decision 3,\n 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.\n */\n if (create && card.key) {\n const saved = await target.savedInto(card.key)\n if (saved.length) {\n target.openSaved(saved[0]!.figureId)\n return proposalOutcomeFrom(saved, say)!\n }\n }\n const accepted = (): FigureProposalOutcome => ({\n verdict: 'accepted',\n applied: steps.length,\n note: say('figure.text.proposal-applied', { n: String(steps.length) })\n })\n\n const detail = target.offer({ key: card.key, steps, create, context: card.context, revision: card.revision })\n if (detail.applied) return accepted()\n if (detail.applied === false) return refused(detail, say)\n\n /* A revision belongs to the figure the model saw; with that modeller gone there is nowhere true to apply it. */\n if (!create) return { verdict: 'unavailable', errorCode: 'modeller-not-open', note: say('figure.text.proposal-needs-modeller') }\n\n const opened = await target.openNew(steps, card.key)\n if (opened.applied) return accepted()\n return opened.code === 'not-opened'\n ? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }\n : refused(opened, say)\n}\n\n/*\n A new figure's proposal handed to the modeller page across a navigation: the handler leaves it here and opens the\n modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new\n figure must not receive steps nobody is waiting for.\n*/\nlet pending: { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void } | undefined\n\nexport function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {\n pending?.settle({ applied: false, code: 'not-opened' })\n return new Promise(resolve => {\n const mine = {\n steps,\n key,\n settle: (result: { applied: boolean; error?: string; code?: string }) => {\n clearTimeout(timer)\n if (pending === mine) pending = undefined\n resolve(result)\n }\n }\n const timer = setTimeout(() => mine.settle({ applied: false, code: 'not-opened' }), ms)\n pending = mine\n })\n}\n\n/** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */\nexport function takePendingNewFigure():\n | { steps: FigureProposalStep[]; key?: string; settle: (result: { applied: boolean; error?: string; code?: string }) => void }\n | undefined {\n const taken = pending\n pending = undefined\n return taken\n}\n"]}
1
+ {"version":3,"file":"ai-proposal.js","sourceRoot":"","sources":["../../client/modeller/ai-proposal.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * An AI proposal reaching the modeller (ADR-0094). The dock's card carries the modeller's own commands; running it\n * applies them as one step of the modeller's history, undone whole, on the version the model saw. Nothing half made\n * is applied, and every outcome is said: applied, refused with the reason, or not possible here.\n *\n * Kept free of Lit, i18n and the shell so node tests read it directly; the handler and the page pass in what they own.\n */\nimport type { FigureProposalStep } from '@hatiolab/figure-model'\nimport type { FigureProductSession } from './product-session.js'\nimport { clipLossOf, editRefusalOf, refusalRecordOf, type FigureRefusalRecord } from './refusals.js'\n\n/** What the card carries, as `proposeFigureCandidate` returned it. */\nexport interface FigureProposalCard {\n /** The proposal's own name, given by the tool: a save records it, a new figure made from it is created under it. */\n key?: string\n steps?: FigureProposalStep[]\n create?: boolean\n figureId?: string\n context?: string\n revision?: number\n}\n\n/**\n * The `figure-apply` event's detail. The page that takes it sets `applied`, and when refused `error` and the refusal in\n * figure-model's shape (`code`, `details`, the `clips` it would stop).\n */\nexport interface FigureApplyDetail extends FigureRefusalRecord {\n key?: string\n steps: FigureProposalStep[]\n create: boolean\n /** The figure a revision was made for: the one the model read. */\n figureId?: string\n context?: string\n revision?: number\n applied?: boolean\n error?: string\n}\n\nexport type Say = (key: string, params?: Record<string, string>) => string\n\nexport interface FigureProposalTarget {\n /** Offers the proposal to a modeller on screen. It comes back untouched when none took it. */\n offer(detail: FigureApplyDetail): FigureApplyDetail\n /** Opens a new figure in the modeller and applies the steps there; resolves once the page has, or has not. */\n openNew(steps: FigureProposalStep[], key?: string): Promise<{ applied: boolean; error?: string; code?: string }>\n /** The figures the server recorded a save of this proposal into (`figureProposalOutcome`), first saved first. */\n savedInto(key: string): Promise<ReadonlyArray<{ figureId: string; type?: string; name?: string }>>\n /** Opens a saved figure in the modeller. */\n openSaved(figureId: string): void\n}\n\nexport interface FigureProposalOutcome {\n verdict: 'accepted' | 'refused' | 'unavailable'\n errorCode?: string\n applied?: number\n note?: string\n}\n\n/**\n * The dock's \"check the result\" (ADR-0097 §5.6 item 5): what the server recorded of a proposal. Saved into figures:\n * accepted, named. Nothing recorded: not known -- applied and not saved yet, undone, or never taken -- so `undefined`,\n * which the dock keeps as \"not known\" and never turns into a refusal. Nothing is run again.\n */\nexport function proposalOutcomeFrom(saved: ReadonlyArray<{ figureId: string; type?: string; name?: string }>, say: Say): FigureProposalOutcome | undefined {\n if (!saved.length) return undefined\n const names = saved.map(f => f.name || f.type || f.figureId).join(', ')\n return { verdict: 'accepted', note: say('figure.text.proposal-saved-into', { names }) }\n}\n\n/** The workspace refuses a request made on another version; said as what happened, not as the kernel's code. */\nconst STALE = new Set(['REVISION_CONFLICT', 'CONTEXT_CONFLICT'])\n\nfunction refused(refusal: FigureRefusalRecord & { error?: string }, say: Say): FigureProposalOutcome {\n const { code, error } = refusal\n const clips = clipLossOf(refusal)\n /*\n A proposal that would stop clips is refused whole; nobody but the person may agree to that, and a proposal is not\n asked about -- the person edits by hand if they mean it (designer 2026-09-27).\n */\n const note =\n code && STALE.has(code)\n ? say('figure.text.proposal-stale')\n : code === 'NEW_FIGURE_UNSAVED'\n ? (error ?? '')\n : clips\n ? say('figure.text.proposal-would-drop-clips', { clips: clips.map(c => c.clip).join(', ') })\n : /* A refusal with a code is said from it, as the modeller says an edit it refuses; never its message (audit N6). */\n say('figure.text.proposal-refused', { reason: code ? editRefusalOf(refusal, (key, params) => say(key, params)) : (error ?? '') })\n return { verdict: 'refused', errorCode: code ?? 'refused', note }\n}\n\n/**\n * The modeller page's way of taking an offer from the dock (`figure-apply`): input changes, or a proposal's commands\n * as one step (ADR-0094). A revision is applied on the version the model saw; a new figure's proposal only on a new\n * figure not yet touched, and a page on a saved figure leaves it untaken. Never with a consent to let clips go: a\n * proposal that would stop one is refused whole, the clips named (designer 2026-09-27). True when it was applied.\n */\nexport function takeFigureOffer(\n session: FigureProductSession,\n detail: FigureApplyDetail & { changes?: unknown },\n { onSavedFigure, say }: { onSavedFigure: boolean; say: Say }\n): boolean {\n const view = session.read()\n if (detail.create && onSavedFigure) return false\n /*\n A revision is for the figure the model read; with another open it is left untaken, and the handler says it needs that\n figure open. The version is no proof of the figure: two figures opened in two page loads stood at the same one\n (figure audit 2026-10-16 N3).\n */\n if (!detail.create && typeof detail.figureId === 'string' && detail.figureId !== (view.id ?? view.creationId)) return false\n if (detail.create && view.dirty) {\n /* A new figure already being made is the person's; a proposal does not go on top of it or replace it. */\n detail.applied = false\n detail.code = 'NEW_FIGURE_UNSAVED'\n detail.error = say('figure.text.proposal-new-figure-unsaved')\n return false\n }\n try {\n if (detail.create) {\n /*\n Taken on the new figure the modeller has open, whose conversation asked for it: the figure keeps its own creation\n id, the anchor of that conversation (ADR-0061 decision 3, ruled 2026-10-16). One proposal stays one figure by the\n save's record of it, which `runFigureProposal` reads before the card runs again.\n */\n session.applyProposal(detail.steps, view.revision!, view.context!, detail.key)\n } else if (detail.steps) session.applyProposal(detail.steps, detail.revision!, detail.context!, detail.key)\n else session.apply(detail.changes as never, 'ai', detail.revision, detail.context)\n detail.applied = true\n return true\n } catch (error) {\n detail.applied = false\n Object.assign(detail, refusalRecordOf(error))\n detail.error = error instanceof Error ? error.message : String(error)\n return false\n }\n}\n\nexport async function runFigureProposal(card: FigureProposalCard, target: FigureProposalTarget, say: Say): Promise<FigureProposalOutcome> {\n const steps = card?.steps\n if (!Array.isArray(steps) || !steps.length) return { verdict: 'unavailable', errorCode: 'no-steps', note: say('figure.text.proposal-empty') }\n const create = card.create === true\n /*\n A new figure's proposal already saved into a figure is not made again: that figure is opened (ADR-0061 decision 3,\n 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.\n */\n if (create && card.key) {\n const saved = await target.savedInto(card.key)\n if (saved.length) {\n target.openSaved(saved[0]!.figureId)\n return proposalOutcomeFrom(saved, say)!\n }\n }\n const accepted = (): FigureProposalOutcome => ({\n verdict: 'accepted',\n applied: steps.length,\n note: say('figure.text.proposal-applied', { n: String(steps.length) })\n })\n\n const detail = target.offer({ key: card.key, steps, create, figureId: create ? undefined : card.figureId, context: card.context, revision: card.revision })\n if (detail.applied) return accepted()\n if (detail.applied === false) return refused(detail, say)\n\n /* A revision belongs to the figure the model saw; with that modeller gone there is nowhere true to apply it. */\n if (!create) return { verdict: 'unavailable', errorCode: 'modeller-not-open', note: say('figure.text.proposal-needs-modeller') }\n\n const opened = await target.openNew(steps, card.key)\n if (opened.applied) return accepted()\n return opened.code === 'not-opened'\n ? { verdict: 'unavailable', errorCode: 'not-opened', note: say('figure.text.proposal-not-opened') }\n : refused(opened, say)\n}\n\n/*\n A new figure's proposal handed to the modeller page across a navigation: the handler leaves it here and opens the\n modeller on a new figure; the page takes it once it has started that figure. Left untaken, it lapses -- a later new\n figure must not receive steps nobody is waiting for.\n*/\ntype Settle = (result: { applied: boolean; error?: string; code?: string }) => void\nlet pending: { steps: FigureProposalStep[]; key?: string; settle: Settle; hold(): void; resume(): void } | undefined\n\nexport function pendNewFigure(steps: FigureProposalStep[], key?: string, ms = 10_000): Promise<{ applied: boolean; error?: string; code?: string }> {\n pending?.settle({ applied: false, code: 'not-opened' })\n return new Promise(resolve => {\n let timer: ReturnType<typeof setTimeout> | undefined\n const lapse = () => mine.settle({ applied: false, code: 'not-opened' })\n const mine = {\n steps,\n key,\n settle: (result: { applied: boolean; error?: string; code?: string }) => {\n clearTimeout(timer)\n if (pending === mine) pending = undefined\n resolve(result)\n },\n hold: () => clearTimeout(timer),\n resume: () => {\n clearTimeout(timer)\n timer = setTimeout(lapse, ms)\n }\n }\n mine.resume()\n pending = mine\n })\n}\n\n/**\n * For a page about to ask the person whether to leave unsaved edits: the new figure waiting to open stops its clock\n * while they answer -- an answer is no absent page (figure audit 2026-10-16 N4). Give it the answer: leaving runs the\n * clock again for the page that opens; staying says now that it was not opened. Nothing waiting: undefined.\n */\nexport function askingToLeave(): ((leaving: boolean) => void) | undefined {\n const waiting = pending\n if (!waiting) return undefined\n waiting.hold()\n return leaving => {\n if (pending !== waiting) return\n if (leaving) waiting.resume()\n else waiting.settle({ applied: false, code: 'not-opened' })\n }\n}\n\n/** For the page, on a new figure it has just started: the steps waiting for it, and where to say how it went. */\nexport function takePendingNewFigure(): { steps: FigureProposalStep[]; key?: string; settle: Settle } | undefined {\n const taken = pending\n pending = undefined\n return taken\n}\n"]}
@@ -65,5 +65,5 @@ export declare const trackEdit: (clip: Clip, track: ClipTrack, patch?: Partial<P
65
65
  clip: string;
66
66
  keys: ClipKey[];
67
67
  loop: "wrap" | "hold";
68
- interpolation: "step" | "linear";
68
+ interpolation: "linear" | "step";
69
69
  };
@@ -1,6 +1,5 @@
1
1
  import { type FigureRevision } from './revision.js';
2
2
  import { type FigureInputChange, type FigurePrimitiveCreation, type FigurePartEdit, type FigureAuthoringAction, type FigureProposalStep, type FigureClipConsent } from '@hatiolab/figure-model';
3
- /** Consent to let clips go, and the figure and version it was given on. */
4
3
  export interface FigureClipConsentFor extends FigureClipConsent {
5
4
  context: string;
6
5
  revision: number;