Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,16 @@ export async function buildAttachmentParts(
}

case 'text': {
// Text excerpted from a node — content is always present
if (att.content && att.content.trim().length > 0) {
parts.push({
type: 'text',
text: `<attachment type="text"${originAttr}>\n${escapeXmlText(att.content)}\n</attachment>`,
});
} else if (originIds.length > 0) {
parts.push({
type: 'text',
text: `<attachment type="node" name="${escapeXmlAttr(label)}"${originAttr} />`,
});
}
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,31 @@ describe('renderEnvelopeMessages', () => {
);
});

it('renders a source-only attachment as a node reference', async () => {
const { messages } = await renderEnvelopeMessages(
makeEnvelope({
text: 'use this source',
attachments: [
{
type: 'text',
source: 'selection',
label: 'Adjacent note',
originNodeId: 'node-adjacent',
},
],
}),
NO_CANVAS,
);

const flat = textOf(messages[0].content);
expect(flat).toContain(
'<attachment type="node" name="Adjacent note" origin="node-adjacent" />',
);
expect(flat.indexOf('origin="node-adjacent"')).toBeLessThan(
flat.indexOf('<user_request>'),
);
});

it('places the sketch-raster hint with the selection visuals', async () => {
const { messages } = await renderEnvelopeMessages(
makeEnvelope({
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/Nodes/MissingFileBanner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ describe('getMissingFileKind', () => {
expect(getMissingFileKind({})).toBeNull();
});

it('ignores non-boolean truthy flag values', () => {
expect(getMissingFileKind({ contentMissing: 'true' })).toBeNull();
expect(getMissingFileKind({ artifactMissing: 1 })).toBeNull();
});

it('distinguishes artifact loss from sidecar loss', () => {
expect(getMissingFileKind({ artifactMissing: true })).toBe('artifact');
expect(getMissingFileKind({ contentMissing: true })).toBe('sidecar');
Expand Down
12 changes: 2 additions & 10 deletions apps/web/src/components/Nodes/MissingFileBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,8 @@ import useCanvasStore from '@/store/canvasStore';

import './MissingFileBanner.css';

export type MissingFileKind = 'sidecar' | 'artifact';

export function getMissingFileKind(data: {
contentMissing?: boolean;
artifactMissing?: boolean;
}): MissingFileKind | null {
if (data.contentMissing) return 'sidecar';
if (data.artifactMissing) return 'artifact';
return null;
}
export { getMissingFileKind } from './missingFile';
export type { MissingFileKind } from './missingFile';

export interface MissingFileBannerProps {
/** Node ID — used by the Remove button to delete this node from the canvas. */
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/components/Nodes/missingFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

export type MissingFileKind = 'sidecar' | 'artifact';

export interface MissingFileData {
contentMissing?: boolean;
artifactMissing?: boolean;
}

export function getMissingFileKind(
data: Record<string, unknown> | MissingFileData,
): MissingFileKind | null {
if (data.contentMissing === true) return 'sidecar';
if (data.artifactMissing === true) return 'artifact';
return null;
}

export function hasMissingFile(
data: Record<string, unknown> | MissingFileData,
): boolean {
return getMissingFileKind(data) !== null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import React, {
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';

import { getMissingFileKind } from '@/components/Nodes/missingFile';
import useCanvasStore from '@/store/canvasStore.ts';
import { useExternalImportsStore } from '@/store/externalImportsStore';
import { usePanelStore } from '@/store/panelStore';
Expand Down Expand Up @@ -124,9 +126,17 @@ const SortableRow = React.memo(
onToggleCollapse,
onToggleLock,
}: SortableRowProps) => {
const { t } = useTranslation();
const { listeners, setNodeRef, isDragging } = useSortable({
id: item.id,
});
const missingFileKind = getMissingFileKind(item.node.data);
const missingFileLabel =
missingFileKind === 'sidecar'
? t('layers.nodeContentFileMissing')
: missingFileKind === 'artifact'
? t('layers.nodeSourceFileMissing')
: undefined;

// Intentionally drop BOTH the active row's drag transform AND the
// sibling rows' strategy transform: the dragged row stays in its
Expand All @@ -148,6 +158,7 @@ const SortableRow = React.memo(
isSelected={isDirectlySelected}
isHighlighted={isHighlighted}
isDragging={isDragging}
missingFileLabel={missingFileLabel}
isCollapsible={isCollapsible}
isCollapsed={isCollapsed}
isLocked={isLocked}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { describe, expect, it } from 'vitest';

import { shouldCanvasSearchOwnKeyboard } from './canvasSearchKeyboard';

describe('shouldCanvasSearchOwnKeyboard', () => {
it('keeps navigation ownership in the search input and result list', () => {
const searchInput = document.createElement('input');
searchInput.dataset.canvasSearchInput = 'true';

const results = document.createElement('div');
results.dataset.canvasSearchResults = '';
const resultButton = document.createElement('button');
results.appendChild(resultButton);

expect(shouldCanvasSearchOwnKeyboard(searchInput)).toBe(true);
expect(shouldCanvasSearchOwnKeyboard(resultButton)).toBe(true);
});

it('keeps ownership when React Flow focuses a plain canvas node', () => {
const canvas = document.createElement('div');
canvas.dataset.canvasRoot = '';
const node = document.createElement('div');
canvas.appendChild(node);

expect(shouldCanvasSearchOwnKeyboard(node)).toBe(true);
});

it('yields to chat and canvas editors while search remains open', () => {
const chatInput = document.createElement('textarea');
const canvas = document.createElement('div');
canvas.dataset.canvasRoot = '';
const noteEditor = document.createElement('div');
noteEditor.setAttribute('contenteditable', 'true');
const editorText = document.createElement('span');
noteEditor.appendChild(editorText);
canvas.appendChild(noteEditor);

expect(shouldCanvasSearchOwnKeyboard(chatInput)).toBe(false);
expect(shouldCanvasSearchOwnKeyboard(noteEditor)).toBe(false);
expect(shouldCanvasSearchOwnKeyboard(editorText)).toBe(false);
});

it('yields to controls outside the search results', () => {
const button = document.createElement('button');

expect(shouldCanvasSearchOwnKeyboard(button)).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { Spline, ChevronDown, ChevronRight, TriangleAlert } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';

import { shouldCanvasSearchOwnKeyboard } from './canvasSearchKeyboard';
import { focusNodesOnCanvas } from './focusNodesOnCanvas';
import { getNodeIcon } from '../../../config/nodeIcons';
import { scheduleScrollToMatch } from '../../../hooks/searchDom';
Expand Down Expand Up @@ -337,14 +338,12 @@ export const CanvasSearchResults = (): React.JSX.Element => {
// bar and any canvas-level Arrow / Enter handlers while a query
// is active.
//
// LOAD-BEARING — capture phase + stopPropagation here suppresses
// *all* canvas-level Enter / Arrow handlers while the result list
// is mounted. That is intentional (we own the keyboard while
// searching); Escape is handled by `CanvasSearchInput` (which
// clears the query and closes the scope — that unmounts this
// component and the keydown listener cleans up).
// Search keeps ownership while focus is in its input/results or React Flow
// has moved focus onto a plain Canvas node wrapper. Editors and controls in
// Chat, Preview, and Canvas keep their own Enter / Arrow behavior.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!shouldCanvasSearchOwnKeyboard(e.target)) return;
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
Expand Down Expand Up @@ -428,7 +427,7 @@ export const CanvasSearchResults = (): React.JSX.Element => {
!isStreaming && query.trim().length > 0 && results.length === 0 && !error;

return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-full min-h-0 flex-col" data-canvas-search-results="">
{/* Truncation banner. VS Code-style: lives at the TOP so the
user spots the warning before scrolling and knows the list
is incomplete. */}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { MissingNodesSummary } from './MissingNodesSummary';

(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;

describe('<MissingNodesSummary>', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;

afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = undefined;
container = undefined;
});

const renderSummary = (
props: Partial<React.ComponentProps<typeof MissingNodesSummary>> = {},
) => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<MissingNodesSummary
count={3}
isActive={false}
isDisabled={false}
onToggle={() => undefined}
onClear={() => undefined}
{...props}
/>,
);
});
};

it('toggles the missing-node filter from the count button', () => {
const onToggle = vi.fn();
renderSummary({ onToggle });

const toggle = container?.querySelector<HTMLButtonElement>(
'button[aria-pressed="false"]',
);
act(() => toggle?.click());

expect(onToggle).toHaveBeenCalledOnce();
});

it('offers an explicit clear action while active', () => {
const onClear = vi.fn();
renderSummary({ isActive: true, onClear });

const clear = container?.querySelector<HTMLButtonElement>(
'button[aria-label="layers.clearMissingFilter"]',
);
act(() => clear?.click());

expect(clear).not.toBeNull();
expect(onClear).toHaveBeenCalledOnce();
});

it('disables filter changes while canvas search is active', () => {
renderSummary({ isDisabled: true });

expect(
container?.querySelector<HTMLButtonElement>(
'button[aria-pressed="false"]',
)?.disabled,
).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import clsx from 'clsx';
import { FileWarning, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';

import { Button } from '../../Common/Button';

interface MissingNodesSummaryProps {
count: number;
isActive: boolean;
isDisabled: boolean;
onToggle: () => void;
onClear: () => void;
}

export const MissingNodesSummary = ({
count,
isActive,
isDisabled,
onToggle,
onClear,
}: MissingNodesSummaryProps) => {
const { t } = useTranslation();
const toggleTitle = isDisabled
? t('layers.clearSearchBeforeMissingFilter')
: isActive
? t('layers.showAllNodes')
: t('layers.showMissingNodesOnly');

return (
<div
className={clsx(
'border-warning-light bg-surface flex shrink-0 items-center border-b px-1.5 py-1',
isActive && 'bg-warning-bg',
)}
>
<Button
variant="ghost"
tone="warning"
size="sm"
onClick={onToggle}
disabled={isDisabled}
title={toggleTitle}
aria-pressed={isActive}
tooltipWrapperClassName="min-w-0 flex-1"
className="w-full min-w-0 justify-start px-1.5!"
>
<FileWarning />
<span className="truncate">
{t('layers.missingNodesCount', { count })}
</span>
</Button>
{isActive && (
<Button
variant="ghost"
tone="warning"
iconOnly
size="sm"
onClick={onClear}
title={t('layers.clearMissingFilter')}
className="p-1!"
>
<X />
</Button>
)}
</div>
);
};
Loading
Loading