@k2b/cloud 0.44.0 → 0.46.0

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 (137) hide show
  1. package/package.json +9 -2
  2. package/src/_internal/app-queries.ts +73 -0
  3. package/src/_internal/capabilities.ts +137 -9
  4. package/src/_internal/define-app.ts +2 -0
  5. package/src/_internal/registry-validation.ts +17 -4
  6. package/src/_internal/registry.ts +32 -4
  7. package/src/_internal/resource-principals.ts +36 -0
  8. package/src/access/GroupCoverage.tsx +20 -5
  9. package/src/access/PermissionEditor.tsx +11 -2
  10. package/src/access/PrincipalAvatar.tsx +30 -21
  11. package/src/access/messages.ts +4 -4
  12. package/src/account/EntitySearch.tsx +12 -0
  13. package/src/ai/capabilities.ts +4 -2
  14. package/src/ai/chat/map-messages.ts +270 -0
  15. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  16. package/src/ai/chat/map-notebook.tsx +301 -0
  17. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  18. package/src/ai/chat/map-widget.css +107 -0
  19. package/src/ai/chat/map-widget.tsx +1279 -0
  20. package/src/ai/chat/message-utils.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +2 -1
  23. package/src/ai/chat/turn-view.tsx +17 -1
  24. package/src/ai/default-tools.ts +3 -0
  25. package/src/ai/dictation-runtime.ts +6 -2
  26. package/src/ai/executor.ts +22 -4
  27. package/src/ai/index.ts +1 -0
  28. package/src/ai/map-contracts.ts +209 -0
  29. package/src/ai/map-execution.ts +14 -0
  30. package/src/ai/map-notebook-save.ts +276 -0
  31. package/src/ai/map-notebook.ts +32 -0
  32. package/src/ai/map-opening-hours.ts +40 -0
  33. package/src/ai/map-routes.ts +84 -0
  34. package/src/ai/map-service.ts +288 -0
  35. package/src/ai/map-store.ts +137 -0
  36. package/src/ai/map-tool.ts +73 -0
  37. package/src/ai/migrate.ts +3 -0
  38. package/src/ai/project-tool.ts +4 -4
  39. package/src/ai/projects-authorization.ts +53 -0
  40. package/src/ai/projects-routes.ts +56 -46
  41. package/src/ai/projects.ts +100 -79
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/recent-resources.ts +3 -3
  44. package/src/ai/routes.ts +32 -30
  45. package/src/ai/skill-tool.ts +4 -4
  46. package/src/ai/skills-routes.ts +27 -26
  47. package/src/ai/skills.ts +120 -81
  48. package/src/ai/store.ts +19 -6
  49. package/src/ai/tools.ts +14 -0
  50. package/src/ai/types.ts +3 -0
  51. package/src/api/accounts-entities.ts +6 -3
  52. package/src/api/admin-ai-skills.ts +4 -1
  53. package/src/api/auth.ts +9 -2
  54. package/src/api/capabilities.ts +90 -12
  55. package/src/api/mcp.ts +4 -2
  56. package/src/browser/notifications.ts +25 -8
  57. package/src/browser/session.ts +39 -0
  58. package/src/browser/shared-text-history-validation.ts +151 -0
  59. package/src/browser/shared-text-history.ts +464 -0
  60. package/src/browser/shared-text-offline-authorized.ts +29 -0
  61. package/src/browser/shared-text-offline.ts +351 -0
  62. package/src/browser/shared-text-provider.ts +714 -0
  63. package/src/browser/shared-text-table-contract.ts +150 -0
  64. package/src/browser/shared-text.ts +19 -0
  65. package/src/capabilities/discussion-providers.ts +99 -0
  66. package/src/capabilities/executions.ts +2 -2
  67. package/src/capabilities/migrate.ts +10 -1
  68. package/src/capabilities/resource-readers.ts +58 -0
  69. package/src/capabilities/server-invoke.ts +23 -13
  70. package/src/capabilities/server.ts +17 -6
  71. package/src/capabilities/testing.ts +13 -0
  72. package/src/contracts/capabilities.ts +137 -4
  73. package/src/contracts/discussions.ts +91 -0
  74. package/src/contracts/geo-document.ts +60 -0
  75. package/src/contracts/geo-snapshot.ts +86 -0
  76. package/src/contracts/geo.ts +14 -0
  77. package/src/contracts/index.ts +2 -0
  78. package/src/contracts/outgoing-mail.ts +1 -1
  79. package/src/contracts/resource-types.ts +21 -0
  80. package/src/contracts/shared.ts +2 -1
  81. package/src/geo/AddressAutocomplete.tsx +213 -0
  82. package/src/geo/messages.ts +29 -0
  83. package/src/geo/navigation.ts +25 -0
  84. package/src/geo/ui.ts +2 -0
  85. package/src/server/api/respond.ts +10 -2
  86. package/src/server/index.ts +1 -0
  87. package/src/server/services/access.ts +23 -10
  88. package/src/server/services/index.ts +1 -0
  89. package/src/services/accounts/app.ts +56 -17
  90. package/src/services/accounts/entities.ts +36 -16
  91. package/src/services/accounts/group-sql.ts +23 -0
  92. package/src/services/accounts/groups.ts +26 -17
  93. package/src/services/accounts/identities.ts +8 -1
  94. package/src/services/accounts/identity-reconciliation.ts +3 -1
  95. package/src/services/accounts/local-groups.ts +141 -27
  96. package/src/services/accounts/machine-group-management.ts +94 -0
  97. package/src/services/accounts/users.ts +38 -8
  98. package/src/services/geo/cache.ts +2 -1
  99. package/src/services/geo/geoapify.ts +3 -0
  100. package/src/services/identity/invocation-token.ts +110 -0
  101. package/src/services/mandates/index.ts +2 -2
  102. package/src/services/mandates/policy.ts +1 -0
  103. package/src/services/pdf/geo-snapshot.ts +107 -0
  104. package/src/services/pdf/index.ts +2 -0
  105. package/src/services/session/index.ts +12 -3
  106. package/src/shared/email-html.ts +1 -0
  107. package/src/shared/markdown/extensions/katex.ts +8 -1
  108. package/src/shared/markdown/extensions/tables.ts +14 -1
  109. package/src/shared/markdown/index.ts +22 -11
  110. package/src/shared-text/anchors.ts +29 -0
  111. package/src/shared-text/awareness.ts +87 -0
  112. package/src/shared-text/changes.ts +27 -0
  113. package/src/shared-text/documents.ts +428 -77
  114. package/src/shared-text/events.ts +12 -2
  115. package/src/shared-text/index.ts +16 -1
  116. package/src/shared-text/log.ts +24 -5
  117. package/src/shared-text/snapshots.ts +69 -23
  118. package/src/shared-text/structural-history.ts +377 -0
  119. package/src/shared-text/table-actions.ts +432 -0
  120. package/src/shared-text/types.ts +24 -5
  121. package/src/ssr/AdminArea.tsx +2 -2
  122. package/src/ssr/AdminLayout.tsx +1 -1
  123. package/src/ssr/AdminSidebar.tsx +1 -1
  124. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  125. package/src/ssr/CloudShell.island.tsx +19 -3
  126. package/src/ssr/Layout.tsx +1 -1
  127. package/src/ssr/MobileProfileActions.tsx +2 -2
  128. package/src/ssr/ProfilePreferences.tsx +2 -1
  129. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  130. package/src/ssr/admin-navigation.ts +1 -11
  131. package/src/ssr/app-launchpad.tsx +6 -1
  132. package/src/ssr/platform-messages.ts +2 -0
  133. package/src/ssr/profile-actions.ts +3 -6
  134. package/src/ssr/shell-data.tsx +1 -1
  135. package/src/styles/global.css +2 -0
  136. package/src/workflows/language/expressions.ts +3 -2
  137. package/src/workflows/runtime/executor.ts +2 -1
@@ -0,0 +1,432 @@
1
+ /** Fixed-character table intents shared by browser capture and server planning. */
2
+ import type { MenuAction, TableTarget } from "@k2b/ui/text-editor";
3
+ import * as T from "@k2b/ui/text-editor/model";
4
+ import type * as Y from "yjs";
5
+ import { captureTextAnchor, resolveTextAnchor } from "./anchors";
6
+ import type { SharedTextChange } from "./types";
7
+
8
+ type RowAction = Extract<MenuAction, "rowAbove" | "rowBelow" | "deleteRow" | "moveRowUp" | "moveRowDown">;
9
+ type RowMove = Extract<RowAction, "moveRowUp" | "moveRowDown">;
10
+ type ColumnMove = Extract<MenuAction, "moveColLeft" | "moveColRight">;
11
+ type TableAction = Extract<MenuAction, "completeColumns" | "toggleHeader" | "deleteTable">;
12
+ type ColumnAction = Exclude<MenuAction, RowAction | TableAction>;
13
+ type TableWitness = { headerBreak: Uint8Array; separatorHyphen: Uint8Array };
14
+ type RowWitness = { kind: "header" } | { kind: "body"; beforeBreak: Uint8Array; inside: Uint8Array; insideKind: "pipe" | "break" };
15
+ type ColumnWitness = { hyphen: Uint8Array; left: Uint8Array; right: Uint8Array };
16
+
17
+ /** Transport encodes the byte arrays; no old numeric row/column survives capture. */
18
+ export type TableActionIntent =
19
+ | { action: TableAction; table: TableWitness }
20
+ | { action: "replaceSource"; table: TableWitness; expected: string; source: string }
21
+ | { action: Exclude<RowAction, RowMove>; table: TableWitness; row: RowWitness }
22
+ | { action: RowMove; table: TableWitness; row: RowWitness; neighbor: RowWitness }
23
+ | { action: Exclude<ColumnAction, ColumnMove>; table: TableWitness; column: ColumnWitness }
24
+ | { action: ColumnMove; table: TableWitness; column: ColumnWitness; neighbor: ColumnWitness };
25
+
26
+ /** Stable conflict for malformed, unsupported, deleted, replaced, or moved targets. */
27
+ export class TableActionRejectedError extends Error {
28
+ readonly code = "table_target_rejected";
29
+
30
+ constructor() {
31
+ super("The table target is unavailable. Inspect the table and retry.");
32
+ this.name = "TableActionRejectedError";
33
+ }
34
+ }
35
+
36
+ export class TableActionLimitError extends RangeError {
37
+ readonly code = "table_action_too_large";
38
+ constructor() {
39
+ super("The table action exceeds the document text limit");
40
+ this.name = "TableActionLimitError";
41
+ }
42
+ }
43
+
44
+ const reject = (): never => {
45
+ throw new TableActionRejectedError();
46
+ };
47
+ const isIndex = (value: number) => Number.isSafeInteger(value) && value >= 0;
48
+ const isBreak = (source: string, at: number) => source[at] === "\n" || source[at] === "\r";
49
+ const hyphenAt = (cell: T.Cell) => cell.from + cell.text.indexOf("-");
50
+
51
+ // Both edges must be actual characters. Optional outer pipes use the physical
52
+ // newline instead; an omitted right pipe at EOF has no safe witness.
53
+ const columnEdges = (table: T.Table, column: number): [number, number] => {
54
+ const separator = table.lines[1]!;
55
+ return [
56
+ separator.leadingPipe ? separator.pipes[column]! : column === 0 ? table.lines[0]!.to : separator.pipes[column - 1]!,
57
+ separator.leadingPipe ? (separator.pipes[column + 1] ?? separator.to) : (separator.pipes[column] ?? separator.to),
58
+ ];
59
+ };
60
+
61
+ /**
62
+ * Call synchronously in structure.perform, before awaiting a flush or request.
63
+ * The host must ensure its edits reached the server. Capture grants no access;
64
+ * applications authorize the document and fence restore revisions externally.
65
+ */
66
+ export const captureTableAction = (text: Y.Text, action: MenuAction, target: TableTarget): TableActionIntent => {
67
+ if (!text.doc) return reject();
68
+ const source = text.toString();
69
+ const table = T.findTables(source).find((candidate) => candidate.from === target.tableFrom);
70
+ const row = table && T.gridLine(table, target.row);
71
+ // UI columnFrom clamps extra body cells to the last header cell. Validate that
72
+ // offset, but never mistake it for an extra column's identity.
73
+ const headerCell = table?.lines[0]?.cells[Math.min(target.column, table.columns - 1)];
74
+ const width = table?.lines.reduce((max, line) => Math.max(max, line.cells.length), 0) ?? 0;
75
+ if (
76
+ !table ||
77
+ !row ||
78
+ !headerCell ||
79
+ !isIndex(target.row) ||
80
+ !isIndex(target.column) ||
81
+ target.column >= width ||
82
+ row.from !== target.rowFrom ||
83
+ headerCell.from !== target.columnFrom
84
+ )
85
+ return reject();
86
+ const anchor = (at: number) => captureTextAnchor(text, at);
87
+ const witness: TableWitness = {
88
+ headerBreak: anchor(table.lines[0]!.to),
89
+ separatorHyphen: anchor(hyphenAt(table.lines[1]!.cells[0]!)),
90
+ };
91
+ const rowWitness = (index: number): RowWitness => {
92
+ if (index === 0) return { kind: "header" };
93
+ const line = T.gridLine(table, index);
94
+ if (!line || index < 1) return reject();
95
+ const inside = line.pipes[0] ?? line.to;
96
+ if (!line.pipes.length && !isBreak(source, inside)) return reject();
97
+ return {
98
+ kind: "body",
99
+ beforeBreak: anchor(table.lines[index]!.to),
100
+ inside: anchor(inside),
101
+ insideKind: line.pipes.length ? "pipe" : "break",
102
+ };
103
+ };
104
+ const columnWitness = (index: number): ColumnWitness => {
105
+ const cell = table.lines[1]!.cells[index];
106
+ if (!cell) return reject();
107
+ const [left, right] = columnEdges(table, index);
108
+ if (right === table.lines[1]!.to && !isBreak(source, right)) return reject();
109
+ return { hyphen: anchor(hyphenAt(cell)), left: anchor(left), right: anchor(right) };
110
+ };
111
+ switch (action) {
112
+ case "completeColumns":
113
+ case "toggleHeader":
114
+ case "deleteTable":
115
+ return { action, table: witness };
116
+ case "rowAbove":
117
+ case "rowBelow":
118
+ case "deleteRow":
119
+ case "moveRowUp":
120
+ case "moveRowDown": {
121
+ const row = rowWitness(target.row);
122
+ if (action === "moveRowUp" || action === "moveRowDown") {
123
+ const to = target.row + (action === "moveRowUp" ? -1 : 1);
124
+ if (target.row === 0 || to < 1) return reject();
125
+ return { action, table: witness, row, neighbor: rowWitness(to) };
126
+ }
127
+ return { action, table: witness, row };
128
+ }
129
+ case "moveColLeft":
130
+ case "moveColRight":
131
+ case "colLeft":
132
+ case "colRight":
133
+ case "deleteCol":
134
+ case "alignLeft":
135
+ case "alignCenter":
136
+ case "alignRight": {
137
+ const column = columnWitness(target.column);
138
+ if (action === "moveColLeft" || action === "moveColRight")
139
+ return { action, table: witness, column, neighbor: columnWitness(target.column + (action === "moveColLeft" ? -1 : 1)) };
140
+ return { action, table: witness, column };
141
+ }
142
+ default:
143
+ return reject();
144
+ }
145
+ };
146
+
147
+ /** An explicit source draft uses the same fixed target and exact source CAS. */
148
+ export const captureTableSource = (text: Y.Text, target: TableTarget, expected: string, source: string): TableActionIntent => {
149
+ const captured = captureTableAction(text, "completeColumns", target);
150
+ return { action: "replaceSource", table: captured.table, expected, source };
151
+ };
152
+
153
+ /** A move changes column identity. Reissue both raw cell spans, including their
154
+ * padding/separator identities, so stale fixed witnesses cannot target the
155
+ * neighbor whose contents the pure text model moved into that physical slot. */
156
+ const moveColumns = (source: string, table: T.Table, column: number, to: number): SharedTextChange[] => {
157
+ const changes = T.moveColumn(table, column, to);
158
+ if (!changes.length) return [];
159
+ const first = Math.min(column, to),
160
+ last = Math.max(column, to);
161
+ return table.lines.map((line) => {
162
+ const from = line.leadingPipe ? line.pipes[first]! + 1 : first === 0 ? line.from : line.pipes[first - 1]! + 1;
163
+ const end = (line.leadingPipe ? line.pipes[last + 1] : line.pipes[last]) ?? line.to;
164
+ const edits = changes
165
+ .filter((change) => change.from >= from && change.to <= end)
166
+ .map((change) => ({ ...change, from: change.from - from, to: change.to - from }));
167
+ return { from, to: end, insert: T.applyChanges(source.slice(from, end), edits), recreate: true as const };
168
+ });
169
+ };
170
+
171
+ /**
172
+ * Reparse a reconstructed Y.Text and plan disjoint model edits without mutation.
173
+ * Apply in descending source order in one Yjs transaction, under the writer's
174
+ * current-head CAS. Only an explicit source draft replaces a whole table.
175
+ */
176
+ export const planTableAction = (text: Y.Text, intent: TableActionIntent, maxLength: number): SharedTextChange[] => {
177
+ try {
178
+ const source = text.toString();
179
+ if (!Number.isSafeInteger(maxLength) || maxLength < 0) return reject();
180
+ const resolve = (bytes: Uint8Array): number => {
181
+ if (!(bytes instanceof Uint8Array)) return reject();
182
+ return resolveTextAnchor(text, bytes) ?? reject();
183
+ };
184
+ const headerBreak = resolve(intent.table.headerBreak);
185
+ const separatorHyphen = resolve(intent.table.separatorHyphen);
186
+ const table = T.findTables(source).find(
187
+ (candidate) =>
188
+ candidate.lines[0]!.to === headerBreak &&
189
+ candidate.lines[1]!.cells.some((cell) => separatorHyphen >= cell.from && separatorHyphen < cell.to),
190
+ );
191
+ if (!table || !isBreak(source, headerBreak) || source[separatorHyphen] !== "-") return reject();
192
+ const resolveRow = (witness: RowWitness): number => {
193
+ if (witness.kind === "header") return 0;
194
+ if (witness.kind !== "body") return reject();
195
+ const before = resolve(witness.beforeBreak),
196
+ inside = resolve(witness.inside);
197
+ if (!isBreak(source, before)) return reject();
198
+ // The preceding newline is deleted by deleteRow. A row's own ending
199
+ // newline alone is unsafe: deletion can leave it attached to another row.
200
+ for (let row = 1; row < T.rowCount(table); row++) {
201
+ const line = T.gridLine(table, row)!;
202
+ if (table.lines[row]!.to !== before) continue;
203
+ if (witness.insideKind === "pipe" && line.pipes.includes(inside)) return row;
204
+ if (witness.insideKind === "break" && line.to === inside && isBreak(source, inside)) return row;
205
+ }
206
+ return reject();
207
+ };
208
+ const resolveColumn = (witness: ColumnWitness): number => {
209
+ const hyphen = resolve(witness.hyphen),
210
+ left = resolve(witness.left),
211
+ right = resolve(witness.right);
212
+ if (source[hyphen] !== "-") return reject();
213
+ const column = table.lines[1]!.cells.findIndex((cell) => hyphen >= cell.from && hyphen < cell.to);
214
+ if (column < 0) return reject();
215
+ const [currentLeft, currentRight] = columnEdges(table, column);
216
+ if (left !== currentLeft || right !== currentRight) return reject();
217
+ if (right === table.lines[1]!.to && !isBreak(source, right)) return reject();
218
+ return column;
219
+ };
220
+ const checkExpansion = (length: number) => {
221
+ if (source.length + length > maxLength) throw new TableActionLimitError();
222
+ };
223
+ const columnInsertion = (column: number) => {
224
+ let length = 0;
225
+ for (const [row, line] of table.lines.entries()) {
226
+ length +=
227
+ column >= line.cells.length
228
+ ? (line.trailingPipe ? 0 : 2) + (column - line.cells.length + 1) * (row === 1 ? 6 : 4)
229
+ : (column === 0 && !line.leadingPipe ? 1 : 0) + (row === 1 ? 6 : 4);
230
+ checkExpansion(length);
231
+ }
232
+ return T.insertColumn(table, column);
233
+ };
234
+ const rowInsertion = (after: number) => {
235
+ checkExpansion((table.lines[0]?.lineBreak || "\n").length + 1 + 4 * table.columns);
236
+ return T.insertRow(table, after);
237
+ };
238
+ switch (intent.action) {
239
+ case "completeColumns": {
240
+ const columns = table.lines.reduce((width, line) => Math.max(width, line.cells.length), table.columns);
241
+ let length = 0;
242
+ for (const [row, line] of table.lines.entries()) {
243
+ const count = columns - line.cells.length;
244
+ if (count) length += (line.trailingPipe ? 0 : 2) + count * (row === 1 ? 6 : 4);
245
+ checkExpansion(length);
246
+ }
247
+ return T.completeColumns(table);
248
+ }
249
+ case "rowAbove":
250
+ return rowInsertion(Math.max(0, resolveRow(intent.row) - 1));
251
+ case "rowBelow":
252
+ return rowInsertion(resolveRow(intent.row));
253
+ case "deleteRow":
254
+ return T.deleteRow(table, resolveRow(intent.row));
255
+ case "moveRowUp": {
256
+ const row = resolveRow(intent.row);
257
+ const to = resolveRow(intent.neighbor);
258
+ if (to !== row - 1) return reject();
259
+ return T.moveRow(source, table, row, to);
260
+ }
261
+ case "moveRowDown": {
262
+ const row = resolveRow(intent.row);
263
+ const to = resolveRow(intent.neighbor);
264
+ if (to !== row + 1) return reject();
265
+ return T.moveRow(source, table, row, to);
266
+ }
267
+ case "moveColLeft": {
268
+ const column = resolveColumn(intent.column);
269
+ const to = resolveColumn(intent.neighbor);
270
+ if (to !== column - 1) return reject();
271
+ return moveColumns(source, table, column, to);
272
+ }
273
+ case "moveColRight": {
274
+ const column = resolveColumn(intent.column);
275
+ const to = resolveColumn(intent.neighbor);
276
+ if (to !== column + 1) return reject();
277
+ return moveColumns(source, table, column, to);
278
+ }
279
+ case "toggleHeader":
280
+ return T.toggleHeader(source, table);
281
+ case "deleteTable":
282
+ return T.deleteTable(table);
283
+ case "replaceSource": {
284
+ if (source.slice(table.from, table.to) !== intent.expected) return reject();
285
+ const replacement = T.findTables(intent.source);
286
+ if (
287
+ replacement.length !== 1 ||
288
+ replacement[0]!.from !== 0 ||
289
+ intent.source.slice(replacement[0]!.to).trim() !== "" ||
290
+ replacement[0]!.lines.some((line) => line.cells.length > replacement[0]!.columns)
291
+ )
292
+ return reject();
293
+ checkExpansion(intent.source.length - intent.expected.length);
294
+ return intent.source === intent.expected ? [] : [{ from: table.from, to: table.to, insert: intent.source }];
295
+ }
296
+ case "colLeft":
297
+ return columnInsertion(resolveColumn(intent.column));
298
+ case "colRight":
299
+ return columnInsertion(resolveColumn(intent.column) + 1);
300
+ case "deleteCol":
301
+ return T.deleteColumn(table, resolveColumn(intent.column));
302
+ case "alignLeft":
303
+ return T.setAlign(table, resolveColumn(intent.column), "left");
304
+ case "alignCenter":
305
+ return T.setAlign(table, resolveColumn(intent.column), "center");
306
+ case "alignRight":
307
+ return T.setAlign(table, resolveColumn(intent.column), "right");
308
+ default:
309
+ return reject();
310
+ }
311
+ } catch (error) {
312
+ if (error instanceof TableActionLimitError) throw error;
313
+ // Transport may supply invalid shapes/bytes despite the static type. Every
314
+ // rejected intent has the same public error, and planning never edits text.
315
+ return reject();
316
+ }
317
+ };
318
+
319
+ /** A focus target is resolved against current identities immediately before use. */
320
+ export type TableFocusWitness = { table: TableWitness; row: RowWitness; column: ColumnWitness };
321
+ export const captureTableFocus = (text: Y.Text, target: TableTarget): TableFocusWitness => {
322
+ const row = captureTableAction(text, "rowBelow", target);
323
+ const column = captureTableAction(text, "alignLeft", target);
324
+ if (!("row" in row) || !("column" in column)) return reject();
325
+ return { table: row.table, row: row.row, column: column.column };
326
+ };
327
+ export const resolveTableFocus = (text: Y.Text, witness: TableFocusWitness): TableTarget | null => {
328
+ try {
329
+ const source = text.toString();
330
+ const at = (bytes: Uint8Array) => resolveTextAnchor(text, bytes);
331
+ const table = T.findTables(source).find(
332
+ (table) =>
333
+ table.lines[0]!.to === at(witness.table.headerBreak) &&
334
+ table.lines[1]!.cells.some((cell) => {
335
+ const hyphen = at(witness.table.separatorHyphen);
336
+ return hyphen !== null && hyphen >= cell.from && hyphen < cell.to;
337
+ }),
338
+ );
339
+ if (!table) return null;
340
+ const hyphen = at(witness.column.hyphen);
341
+ const column = table.lines[1]!.cells.findIndex((cell) => hyphen !== null && hyphen >= cell.from && hyphen < cell.to);
342
+ if (column < 0 || source[hyphen!] !== "-") return null;
343
+ const edges = columnEdges(table, column);
344
+ if (at(witness.column.left) !== edges[0] || at(witness.column.right) !== edges[1]) return null;
345
+ const rowWitness = witness.row;
346
+ const row =
347
+ rowWitness.kind === "header"
348
+ ? 0
349
+ : Array.from({ length: T.rowCount(table) - 1 }, (_, index) => index + 1).find((index) => {
350
+ const line = T.gridLine(table, index)!;
351
+ return (
352
+ table.lines[index]!.to === at(rowWitness.beforeBreak) &&
353
+ (rowWitness.insideKind === "pipe" ? line.pipes.includes(at(rowWitness.inside) ?? -1) : line.to === at(rowWitness.inside))
354
+ );
355
+ });
356
+ if (row === undefined) return null;
357
+ return { tableFrom: table.from, rowFrom: T.gridLine(table, row)!.from, columnFrom: table.lines[0]!.cells[column]!.from, row, column };
358
+ } catch {
359
+ return null;
360
+ }
361
+ };
362
+
363
+ /** Resolve the selection at the planned head, then capture the ACTUAL receipt IDs. */
364
+ export const prepareTableActionFocus = (
365
+ before: Y.Text,
366
+ intent: TableActionIntent,
367
+ focus: TableFocusWitness,
368
+ caret?: { row: number; column: number },
369
+ ): ((after: Y.Text) => TableFocusWitness | null) => {
370
+ const target = resolveTableFocus(before, focus);
371
+ const table = target && T.findTables(before.toString()).find((table) => table.from === target.tableFrom);
372
+ if (!target || !table || resolveTextAnchor(before, intent.table.headerBreak) !== table.lines[0]!.to) return () => null;
373
+ let { row, column } = target;
374
+ // Row/column intents must identify the same selected identity. The request's
375
+ // optional focus is presentation, never authority to change another target.
376
+ if ("row" in intent) {
377
+ const selected = resolveTableFocus(before, { ...focus, row: intent.row });
378
+ if (!selected || selected.row !== row) return () => null;
379
+ }
380
+ if ("column" in intent) {
381
+ const selected = resolveTableFocus(before, { ...focus, column: intent.column });
382
+ if (!selected || selected.column !== column) return () => null;
383
+ }
384
+ switch (intent.action) {
385
+ case "deleteTable":
386
+ return () => null;
387
+ case "rowAbove":
388
+ row = Math.max(1, row);
389
+ break;
390
+ case "rowBelow":
391
+ row++;
392
+ break;
393
+ case "moveRowUp":
394
+ row--;
395
+ break;
396
+ case "moveRowDown":
397
+ row++;
398
+ break;
399
+ case "colRight":
400
+ case "moveColRight":
401
+ column++;
402
+ break;
403
+ case "moveColLeft":
404
+ column--;
405
+ break;
406
+ case "toggleHeader":
407
+ row = table.lines[0]!.cells.some((cell) => cell.text) ? row + 1 : Math.max(0, row - 1);
408
+ break;
409
+ case "replaceSource":
410
+ row = caret?.row ?? row;
411
+ column = caret?.column ?? column;
412
+ break;
413
+ }
414
+ const from = table.from;
415
+ return (after) => {
416
+ const table = T.findTables(after.toString()).find((table) => table.from === from);
417
+ if (!table) return null;
418
+ const selectedRow = Math.max(0, Math.min(row, T.rowCount(table) - 1));
419
+ const selectedColumn = Math.max(0, Math.min(column, table.columns - 1));
420
+ try {
421
+ return captureTableFocus(after, {
422
+ tableFrom: table.from,
423
+ rowFrom: T.gridLine(table, selectedRow)!.from,
424
+ columnFrom: table.lines[0]!.cells[selectedColumn]!.from,
425
+ row: selectedRow,
426
+ column: selectedColumn,
427
+ });
428
+ } catch {
429
+ return null;
430
+ }
431
+ };
432
+ };
@@ -1,14 +1,22 @@
1
+ import type { SharedTextStructuralHistory } from "./structural-history";
1
2
  export type SharedTextActor = { kind: "user" | "service_account"; id: string };
2
3
 
3
4
  /** Actor is optional so events retained from older deployments remain readable. */
4
5
  export type SharedTextEvent = {
5
- kind: "sync" | "awareness";
6
+ kind: "sync" | "awareness" | "freeze";
6
7
  payload: string;
7
8
  originNodeId: string;
8
9
  originPeerId: string | null;
9
10
  actor?: SharedTextActor;
11
+ /** Echoed only by authorized server actions, for the initiating browser's undo history. */
12
+ actionId?: string;
13
+ /** Authored only by the server structural writer/admission boundary; retained with the event. */
14
+ history?: SharedTextStructuralHistory;
15
+ /** Updates apply only to the store lineage from which they originated. */
16
+ restoreRevision?: string;
10
17
  };
11
- export type SharedTextSyncEvent = SharedTextEvent & { kind: "sync" };
18
+ /** Ordered document events; a freeze marker carries no text update. */
19
+ export type SharedTextSyncEvent = SharedTextEvent & { kind: "sync" | "freeze" };
12
20
  export type SharedTextContributor = SharedTextActor & { lastContributedAt: Date };
13
21
 
14
22
  /** The store anchors legacy cursors to this log before returning a snapshot. */
@@ -27,13 +35,24 @@ export type SharedTextSaveInput = {
27
35
  actor: SharedTextActor | null;
28
36
  contributors: SharedTextContributor[];
29
37
  };
30
- export type SharedTextSnapshot = { state: Uint8Array | null; text: string | null; cursor: string | null };
38
+ export type SharedTextSnapshot = SharedTextStoredState;
39
+ /** A disjoint edit in the original document's UTF-16 coordinates. */
40
+ export type SharedTextChange = {
41
+ from: number;
42
+ to: number;
43
+ insert: string;
44
+ /** Authorized identity fence: recreate a nonempty range even when its text is equal. */
45
+ recreate?: true;
46
+ };
47
+
48
+ export type SharedTextActionReceipt = { actionId: string; update: Uint8Array };
49
+
31
50
  export type SharedTextEditResult<Saved> =
32
51
  | { status: "notFound" }
33
52
  | { status: "unchanged"; text: string }
34
53
  | { status: "syncFailed"; error: unknown }
35
- | { status: "persistFailed"; cursor: string; error: unknown }
36
- | { status: "edited"; text: string; cursor: string; saved: Saved };
54
+ | { status: "persistFailed"; cursor: string; error: unknown; receipt?: SharedTextActionReceipt }
55
+ | { status: "edited"; text: string; cursor: string; saved: Saved; receipt?: SharedTextActionReceipt };
37
56
 
38
57
  export type SharedTextStore<Saved, Context> = {
39
58
  load(docId: string, options: { signal?: AbortSignal }): Promise<SharedTextStoredState | null>;
@@ -84,7 +84,7 @@ export const AdminAreaSaveStatusHead = () => (
84
84
  </div>
85
85
  );
86
86
  export const AdminAreaSaveStatusFoot = () => (
87
- <div class="sticky bottom-0 flex justify-end bg-(--k2b-surface) px-4 py-3 md:hidden">
87
+ <div class="sticky bottom-0 flex justify-end bg-(--k2b-surface) px-2 py-3 md:hidden">
88
88
  <SettingsSaveStatus />
89
89
  </div>
90
90
  );
@@ -124,7 +124,7 @@ export function AdminAreaPane(props: {
124
124
  minSize={props.minSize}
125
125
  defaultSize={props.minSize}
126
126
  >
127
- <div class="app-rows px-[var(--ui-space-shell)] pb-[var(--ui-space-shell)]">{props.children}</div>
127
+ <div class="app-rows pb-[var(--ui-space-shell)]">{props.children}</div>
128
128
  {props.saveStatus ? <AdminAreaSaveStatusFoot /> : null}
129
129
  </AppWorkspace.MainPane>
130
130
  );
@@ -47,7 +47,7 @@ export default function AdminLayout(props: Props) {
47
47
  const breadcrumbs: Breadcrumb[] = [{ title: t.start, href: "/" }, { title: overview ? t.admin : title }];
48
48
  return (
49
49
  <Layout c={c} fullWidth fullPage title={breadcrumbs}>
50
- <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1" resizable={false}>
50
+ <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
51
51
  <AdminSidebar currentPath={currentPath} apps={runtime.apps} />
52
52
  <AppWorkspace.Content>
53
53
  {props.ownMain ? (
@@ -52,7 +52,7 @@ export default function AdminSidebar({ currentPath, apps }: { currentPath: strin
52
52
  })),
53
53
  }))}
54
54
  />
55
- <AppWorkspace.Sidebar resizable={false}>
55
+ <AppWorkspace.Sidebar>
56
56
  <AppWorkspace.SidebarDesktop>
57
57
  <AppWorkspace.SidebarBody scrollPreserveKey="admin-sidebar">
58
58
  <AdminNavigation currentPath={currentPath} groups={groups()} />
@@ -29,7 +29,7 @@ export type AppLaunchpadLegalLink = {
29
29
  };
30
30
 
31
31
  export type AppLaunchpadContext = {
32
- profile?: { name: string; theme: CloudThemePreference; appHref?: string };
32
+ profile?: { userId?: string; name: string; theme: CloudThemePreference; appHref?: string };
33
33
  apps: AppLaunchpadApp[];
34
34
  legalLinks: AppLaunchpadLegalLink[];
35
35
  };
@@ -111,7 +111,12 @@ function RailNavigation(props: { currentPath: string; panelOpen?: boolean }) {
111
111
  const locale = useLocale();
112
112
  const t = () => platformMessages.resolve([locale()]).t;
113
113
  if (!data?.rail || !data.profile) return null;
114
- const profile = { name: data.profile.name, theme: data.theme, ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}) };
114
+ const profile = {
115
+ userId: data.profile.userId,
116
+ name: data.profile.name,
117
+ theme: data.theme,
118
+ ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}),
119
+ };
115
120
  createEffect(() => setAppLaunchpadContext(data.apps, data.legalLinks, profile));
116
121
  return (
117
122
  <nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={t().apps}>
@@ -128,6 +133,7 @@ function RailNavigation(props: { currentPath: string; panelOpen?: boolean }) {
128
133
  helpOpen={!!props.panelOpen && data.panel?.provider === "core"}
129
134
  />
130
135
  <ProfilePreferences
136
+ userId={data.profile.userId}
131
137
  appHref={data.profile.appHref}
132
138
  avatarSrc={data.profile.avatarSrc}
133
139
  initialTheme={data.theme}
@@ -164,7 +170,12 @@ function HeaderControls(props: {
164
170
  <AppLaunchpadButton
165
171
  profile={
166
172
  data.profile
167
- ? { name: data.profile.name, theme: data.theme, ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}) }
173
+ ? {
174
+ userId: data.profile.userId,
175
+ name: data.profile.name,
176
+ theme: data.theme,
177
+ ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}),
178
+ }
168
179
  : undefined
169
180
  }
170
181
  apps={data.apps}
@@ -188,7 +199,12 @@ function HeaderControls(props: {
188
199
  <AppLaunchpadButton
189
200
  profile={
190
201
  data.profile
191
- ? { name: data.profile.name, theme: data.theme, ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}) }
202
+ ? {
203
+ userId: data.profile.userId,
204
+ name: data.profile.name,
205
+ theme: data.theme,
206
+ ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}),
207
+ }
192
208
  : undefined
193
209
  }
194
210
  apps={data.apps}
@@ -271,7 +271,7 @@ export default function Layout(props: LayoutProps) {
271
271
  ? {
272
272
  rail: c.get("railPreferences") ?? defaultRailPreferences(),
273
273
  home: resolveHomePath(settings?.app?.home_path),
274
- profile: { name: profileName, avatarSrc: profileAvatarSrc, appHref },
274
+ profile: { userId: user.id, name: profileName, avatarSrc: profileAvatarSrc, appHref },
275
275
  }
276
276
  : {}),
277
277
  legalLinks,
@@ -5,10 +5,10 @@ import { createProfileActions } from "./profile-actions";
5
5
  import { ThemeCycleButton } from "./ThemeCycleButton";
6
6
 
7
7
  export function MobileProfileActions(props: {
8
- profile: { name: string; theme: CloudThemePreference; appHref?: string };
8
+ profile: { userId?: string; name: string; theme: CloudThemePreference; appHref?: string };
9
9
  beforeSelect: () => Promise<boolean>;
10
10
  }) {
11
- const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme, props.profile.appHref);
11
+ const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme, props.profile.appHref, props.profile.userId);
12
12
  const navigate = (href: string) => (event: MouseEvent) => {
13
13
  if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
14
14
  event.preventDefault();
@@ -3,6 +3,7 @@ import type { CloudThemePreference } from "../shared/theme";
3
3
  import { createProfileActions } from "./profile-actions";
4
4
 
5
5
  type ProfilePreferencesProps = {
6
+ userId?: string;
6
7
  /** Set while the installation offers the mobile app. */
7
8
  appHref?: string;
8
9
  avatarSrc?: string;
@@ -16,7 +17,7 @@ type ProfilePreferencesProps = {
16
17
  * bottom edge.
17
18
  */
18
19
  export default function ProfilePreferences(props: ProfilePreferencesProps) {
19
- const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme, props.appHref);
20
+ const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme, props.appHref, props.userId);
20
21
 
21
22
  const avatar = () => <Avatar name={props.name} src={props.avatarSrc} size="sm" />;
22
23