|
630 | 630 | }; |
631 | 631 |
|
632 | 632 | const upsertNodeFromStream = (node: ThreadNodePayload) => { |
| 633 | + // If this node is an edit of another message, do NOT display it separately. |
| 634 | + // Instead, update the original message to point to this edit, and register |
| 635 | + // the edit key so subsequent edits in the chain can find the original. |
| 636 | + // EXCEPTION: If messages is empty, this is the first/root node being streamed |
| 637 | + // (e.g., from a search selection), so we should display it even if it's an edit. |
| 638 | + if (node.editOf && messages.length > 0) { |
| 639 | + // Walk the edit chain to find the original (non-edit) message |
| 640 | + let originalKey = node.editOf; |
| 641 | + let visited = new Set<string>(); |
| 642 | + while (originalKey && !visited.has(originalKey)) { |
| 643 | + visited.add(originalKey); |
| 644 | + const path = keyToPath.get(originalKey); |
| 645 | + if (path) { |
| 646 | + const msg = getMessageAtPath(messages, path); |
| 647 | + if (msg && msg.editOf) { |
| 648 | + // This message is itself an edit, keep walking back |
| 649 | + originalKey = msg.editOf; |
| 650 | + } else { |
| 651 | + // Found the original message |
| 652 | + break; |
| 653 | + } |
| 654 | + } else { |
| 655 | + break; |
| 656 | + } |
| 657 | + } |
| 658 | +
|
| 659 | + const originalPath = keyToPath.get(originalKey); |
| 660 | + if (originalPath) { |
| 661 | + updateMessageAtPath(originalPath, (message) => { |
| 662 | + // Update the original message with the edit's content metadata |
| 663 | + message.mimeType = node.mimeType; |
| 664 | + message.isText = node.isText; |
| 665 | + message.sizeBytes = node.sizeBytes; |
| 666 | + // Keep original createdAt to preserve position in sort order |
| 667 | + if (node.computedId) { |
| 668 | + message.computedId = node.computedId; |
| 669 | + } |
| 670 | + // Track that this message has been edited and store the edit key |
| 671 | + message.suggestedEdit = node.key; |
| 672 | + message.resolvedKey = node.key; |
| 673 | + if (node.title) { |
| 674 | + message.title = node.title; |
| 675 | + if (!message.attachmentName) { |
| 676 | + message.attachmentName = node.title; |
| 677 | + } |
| 678 | + } |
| 679 | + }); |
| 680 | + // Register the edit key to point to the original's path |
| 681 | + if (node.key) { |
| 682 | + keyToPath.set(node.key, originalPath); |
| 683 | + } |
| 684 | + requestMessageHydration(node); |
| 685 | + return; |
| 686 | + } |
| 687 | + // If original not found, skip this edit entirely - don't create a duplicate |
| 688 | + return; |
| 689 | + } |
| 690 | +
|
633 | 691 | const existingPath = node.key ? keyToPath.get(node.key) : undefined; |
634 | 692 | if (existingPath) { |
635 | 693 | updateMessageAtPath(existingPath, (message) => { |
|
1076 | 1134 | if (!node.key) { |
1077 | 1135 | return; |
1078 | 1136 | } |
1079 | | - const cached = await readCachedMessageRecord(node.key); |
| 1137 | + // Use suggestedEdit key if available - this points to the latest edit content |
| 1138 | + const hydrateKey = node.suggestedEdit || node.key; |
| 1139 | +
|
| 1140 | + // Map the hydration key to the original's path BEFORE hydrating, |
| 1141 | + // so when the result comes back, applyRecordToMessage can find it |
| 1142 | + if (hydrateKey !== node.key) { |
| 1143 | + const path = keyToPath.get(node.key); |
| 1144 | + if (path) { |
| 1145 | + keyToPath.set(hydrateKey, path); |
| 1146 | + } |
| 1147 | + } |
| 1148 | +
|
| 1149 | + const cached = await readCachedMessageRecord(hydrateKey); |
1080 | 1150 | if (cached) { |
1081 | | - applyRecordToMessage(cached); |
| 1151 | + applyRecordToMessage({ ...cached, key: node.key }); |
1082 | 1152 | return; |
1083 | 1153 | } |
1084 | 1154 | // For text nodes, we hydrate via the bulk worker (it will provide content |
1085 | 1155 | // and metadata). For non-text nodes we avoid downloading the full payload |
1086 | 1156 | // (could be large) and instead perform a small Range fetch to get headers |
1087 | 1157 | // such as X-Ouroboros-Title. |
1088 | 1158 | if (node.isText) { |
1089 | | - await enqueueBulkHydration(node.key); |
| 1159 | + await enqueueBulkHydration(hydrateKey); |
1090 | 1160 | return; |
1091 | 1161 | } |
1092 | 1162 | try { |
1093 | | - const record = await fetchMessageMetadata(node); |
| 1163 | + const record = await fetchMessageMetadata({ ...node, key: hydrateKey }); |
1094 | 1164 | if (record) { |
| 1165 | + // Store with the hydrate key but apply to the original message |
1095 | 1166 | persistMessageRecord(record); |
1096 | | - applyRecordToMessage(record); |
| 1167 | + applyRecordToMessage({ ...record, key: node.key }); |
1097 | 1168 | } |
1098 | 1169 | } catch (error) { |
1099 | 1170 | console.warn('Failed to fetch message metadata', error); |
|
1263 | 1334 | if (node.key) { |
1264 | 1335 | map.set(node.key, current); |
1265 | 1336 | } |
| 1337 | + // Also map suggestedEdit and resolvedKey to the same path, |
| 1338 | + // so hydration results from edit keys can find the original message |
| 1339 | + if (node.suggestedEdit && node.suggestedEdit !== node.key) { |
| 1340 | + map.set(node.suggestedEdit, current); |
| 1341 | + } |
| 1342 | + if (node.resolvedKey && node.resolvedKey !== node.key) { |
| 1343 | + map.set(node.resolvedKey, current); |
| 1344 | + } |
1266 | 1345 | traverse(node.children, current); |
1267 | 1346 | }); |
1268 | 1347 | }; |
|
1953 | 2032 | mime_type: mimeType, |
1954 | 2033 | is_text: true, |
1955 | 2034 | filename: 'message.txt', |
1956 | | - edit_of: target.key |
| 2035 | + // Use resolvedKey or suggestedEdit if available - this ensures edits form |
| 2036 | + // a chain (Test1 → Test2 → Test3 → Test4) rather than all pointing to the |
| 2037 | + // original (Test1 ← Test2, Test1 ← Test3, Test1 ← Test4). |
| 2038 | + edit_of: target.resolvedKey || target.suggestedEdit || target.key |
1957 | 2039 | }; |
1958 | 2040 | if (target.parentKey) { |
1959 | 2041 | metadata.parent = target.parentKey; |
|
1996 | 2078 |
|
1997 | 2079 | const updated = getMessageAtPath(messages, editTargetPath); |
1998 | 2080 | if (updated && updated.key) { |
1999 | | - persistMessageRecord(messageToRecord(updated)); |
| 2081 | + // Cache with both the original key AND the new edit key. |
| 2082 | + // On reload, we'll hydrate using suggestedEdit (the new key), |
| 2083 | + // so it needs to be in the cache. |
| 2084 | + const record = messageToRecord(updated); |
| 2085 | + persistMessageRecord(record); |
| 2086 | + if (key && key !== updated.key) { |
| 2087 | + // Also cache under the new edit key so hydration can find it |
| 2088 | + persistMessageRecord({ ...record, key: key }); |
| 2089 | + } |
2000 | 2090 | } |
2001 | 2091 |
|
2002 | 2092 | statusState = 'success'; |
|
2739 | 2829 | top: 0; |
2740 | 2830 | background: var(--surface-raised); |
2741 | 2831 | padding: 0.75rem; |
| 2832 | + z-index: 100; |
2742 | 2833 | } |
2743 | 2834 |
|
2744 | 2835 | .conversation-header h1 { |
|
0 commit comments