diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 8fb65313f..396f13bac 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -163,6 +163,11 @@ import { replaceSavedDetailArchive, sanitizeDetailArchiveList, } from "@/components/canvas/chat-panel/canvas-detail-archive"; +import { + applyDetailSlotAttachment, + fillDetailSlotAttachments, + getDetailSlotTargetIndexes, +} from "@/components/canvas/chat-panel/canvas-detail-slots"; import { clampChatRequestText, deriveSessionTitleFromMessages, @@ -4954,11 +4959,7 @@ export function CanvasChatPanel({ const updateDetailSlotAttachment = useCallback( (slotIndex: number, attachment: PickedObjectAttachment | null) => { - setDetailSlots((current) => { - const next = [...current]; - next[slotIndex] = attachment; - return next; - }); + setDetailSlots((current) => applyDetailSlotAttachment(current, slotIndex, attachment)); }, [] ); @@ -4968,13 +4969,7 @@ export function CanvasChatPanel({ if (slotIndex < 0 || slotIndex >= DETAIL_SLOT_CONFIGS.length) return 0; if (selectedImageNodes.length === 0) return 0; - const slotRole = DETAIL_SLOT_CONFIGS[slotIndex]?.role; - const sameRoleIndexes = DETAIL_SLOT_CONFIGS.map((slot, index) => - slot.role === slotRole ? index : -1 - ).filter((index) => index >= 0); - const startOffset = sameRoleIndexes.indexOf(slotIndex); - const targetIndexes = - startOffset >= 0 ? sameRoleIndexes.slice(startOffset) : sameRoleIndexes; + const targetIndexes = getDetailSlotTargetIndexes(slotIndex); if (targetIndexes.length === 0) return 0; const candidateAttachments = selectedImageNodes @@ -4990,13 +4985,9 @@ export function CanvasChatPanel({ let filledCount = 0; setDetailSlots((current) => { - const next = [...current]; - for (let i = 0; i < targetIndexes.length && i < candidateAttachments.length; i += 1) { - const index = targetIndexes[i]!; - next[index] = candidateAttachments[i]!; - filledCount += 1; - } - return next; + const result = fillDetailSlotAttachments(current, targetIndexes, candidateAttachments); + filledCount = result.filledCount; + return result.slots; }); return filledCount; }, diff --git a/components/canvas/chat-panel/canvas-detail-slots.ts b/components/canvas/chat-panel/canvas-detail-slots.ts new file mode 100644 index 000000000..f944d115f --- /dev/null +++ b/components/canvas/chat-panel/canvas-detail-slots.ts @@ -0,0 +1,48 @@ +import { + type DetailSlotConfig, + DETAIL_SLOT_CONFIGS, +} from "@/components/canvas/chat-panel/canvas-detail-config"; +import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session"; + +export function getDetailSlotTargetIndexes( + slotIndex: number, + slotConfigs: DetailSlotConfig[] = DETAIL_SLOT_CONFIGS +): number[] { + if (slotIndex < 0 || slotIndex >= slotConfigs.length) return []; + const slotRole = slotConfigs[slotIndex]?.role; + const sameRoleIndexes = slotConfigs + .map((slot, index) => (slot.role === slotRole ? index : -1)) + .filter((index) => index >= 0); + const startOffset = sameRoleIndexes.indexOf(slotIndex); + return startOffset >= 0 ? sameRoleIndexes.slice(startOffset) : sameRoleIndexes; +} + +export function applyDetailSlotAttachment( + current: Array, + slotIndex: number, + attachment: PickedObjectAttachment | null +): Array { + const next = [...current]; + if (slotIndex < 0) return next; + next[slotIndex] = attachment; + return next; +} + +export function fillDetailSlotAttachments( + current: Array, + targetIndexes: number[], + attachments: PickedObjectAttachment[] +): { + slots: Array; + filledCount: number; +} { + const next = [...current]; + let filledCount = 0; + for (let i = 0; i < targetIndexes.length && i < attachments.length; i += 1) { + const index = targetIndexes[i]!; + if (index < 0) continue; + next[index] = attachments[i]!; + filledCount += 1; + } + return { slots: next, filledCount }; +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 95cfc5853..ba1659ccd 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -33,6 +33,12 @@ import { replaceSavedDetailArchive, sanitizeDetailArchiveList, } from "@/components/canvas/chat-panel/canvas-detail-archive"; +import { + applyDetailSlotAttachment, + fillDetailSlotAttachments, + getDetailSlotTargetIndexes, +} from "@/components/canvas/chat-panel/canvas-detail-slots"; +import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session"; import type { ChatMessage } from "@/lib/types"; test.describe("canvas chat pure helpers", () => { @@ -404,4 +410,38 @@ test.describe("canvas chat pure helpers", () => { })); expect(prependDetailArchive(manyArchives, saved)).toHaveLength(30); }); + + test("fills detail slots by current role group without crossing groups", () => { + expect(getDetailSlotTargetIndexes(0)).toEqual([0, 1, 2, 3, 4]); + expect(getDetailSlotTargetIndexes(3)).toEqual([3, 4]); + expect(getDetailSlotTargetIndexes(5)).toEqual([5]); + expect(getDetailSlotTargetIndexes(7)).toEqual([7, 8]); + expect(getDetailSlotTargetIndexes(-1)).toEqual([]); + expect(getDetailSlotTargetIndexes(99)).toEqual([]); + + const current = Array.from({ length: DETAIL_SLOT_CONFIGS.length }, () => null); + const productA: PickedObjectAttachment = { + name: "product-a", + url: "https://cdn.example.com/product-a.png", + contentType: "image/png", + }; + const productB: PickedObjectAttachment = { + name: "product-b", + url: "https://cdn.example.com/product-b.png", + contentType: "image/png", + }; + + const updatedSingle = applyDetailSlotAttachment(current, 5, productA); + expect(updatedSingle[5]).toBe(productA); + expect(current[5]).toBeNull(); + + const filled = fillDetailSlotAttachments(current, getDetailSlotTargetIndexes(3), [ + productA, + productB, + ]); + expect(filled.filledCount).toBe(2); + expect(filled.slots[3]).toBe(productA); + expect(filled.slots[4]).toBe(productB); + expect(filled.slots[5]).toBeNull(); + }); });