mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(web): free-pin fallback in comment mode for unannotated artifacts When the artifact has no data-od-id annotations, clicking in Comment mode now posts a synthetic position-based target so the host opens a popover at the click location. Daemon upsert validation requires a non-empty selector/label, so the pin uses [data-od-pin=ID] and label 'pin'. Coordinates are document-space (viewport + scrollY) so pins stay anchored after scroll/reload. Clicks on interactive elements (a/button/input/textarea/select/label/contenteditable) keep their native behavior and are not pinned. * feat(web): tighten comment popover layout for free-pin and element targets The popover header used to dump the raw elementId verbatim — fine for data-od-id targets like 'hero-cta' but jarring for free-pins where elementId is a synthetic 'pin-...' string. Branch on the prefix and show 'Pin · at X, Y' for free-pins; keep the label + selection kind for real element / pod targets. Replace the text 'Close' button with an icon-only close affordance to match the popover-as-card visual. Action row is now two right-aligned buttons (Comment + Send to Claude) for element targets and (Add note + Send to Claude) for pod targets, eliminating the three-button row that wrapped onto two lines at narrow widths. The 'Remove' affordance for existing comments stays left-aligned. * feat(web): drop comments tab from chat sidebar The chat sidebar's 'Comments' tab listed saved/attached preview comments but duplicates the per-element popover already shown in the artifact viewer. Hide the tab and its content while the right-side comment thread panel takes over the same surface in-context. The CommentsPanel / CommentSection components stay defined as dead code for the moment so callers and translation keys remain valid; a later pass can delete them. * feat(web): right-side comment thread panel in board mode Render a 320px CommentSidePanel anchored to the right of the artifact preview whenever board (comment) mode is on. The panel lists every saved preview comment for the current file with an avatar initial, the element label (or 'Pin' for free-pin synthetic ids), an Xd/Xh/Xm-ago timestamp, the note body, a Reply link, and a checkbox. Reply focuses the comment's element via liveSnapshotForComment so the popover opens at the right anchor. Selecting one or more comments via the checkboxes surfaces a 'N selected · Clear · Send to Claude' action bar above the list; Send to Claude reuses the existing onSendBoardCommentAttachments pipeline via commentsToAttachments. The panel takes the place of the chat sidebar's removed Comments tab so the thread lives next to the artifact instead of behind a tab switch. * feat(web): styles for right-side comment thread panel Floating 320px panel anchored to the right edge of the artifact preview with a scrollable comment list and a coral selection bar that appears when one or more comments are checked. Selected items get a coral tint; the reply / check / send-to-claude controls match the popover's coral primary tone. * feat(web): toast confirmation on comment save, close popover After savePersistentComment succeeds, close the popover via clearBoardComposer and surface a transient 'Comment saved' (or 'Pin saved' for free-pin targets) toast for 2.2s. Replaces the previous behavior where the popover stayed open with an empty draft after save, which left users uncertain whether the save landed and forced an extra click to dismiss. * feat(web): position the comment-save toast at the top of the preview * feat(web): allow editing saved comment notes via the side panel Rename the per-item 'Reply' affordance to 'Edit' (no thread model exists yet, so reply was misleading) and pre-fill the popover with the existing note when clicked. The save path goes through onSavePreviewComment which the daemon implements as an upsert keyed on (project, conversation, filePath, elementId), so the edit overwrites the existing row's note without spawning a duplicate. Also fall back to a snapshot synthesized from the saved comment's own fields when the corresponding live target is no longer in the iframe DOM (e.g. free-pin parents that were re-rendered), so the edit path still works after artifact reloads. * feat(web): hide already-sent comments from the side panel After Send to Claude, the daemon flips the comment status from 'open' to 'applying' (and then 'needs_review' / 'resolved' / 'failed' depending on the run). Filter the side panel to status === 'open' so sent comments visibly leave the list — the user gets clear feedback that the send landed and the panel stays focused on actionable, un-sent items. * feat(web): drop single-tab bar and conversation count badge After the Comments tab was removed the chat header still rendered a one-tab 'tablist' just for the Chat tab, which read as visual noise without a sibling to switch between. Drop the tabs wrapper entirely; the chat content stays mounted and the header now hosts only the conversation-history affordance. Also drop the numeric badge that overlaid the conversation history button: counting open conversations next to a generic history icon was easy to mistake for an unread / notification count. The dropdown itself remains the canonical place to see and switch between past conversations. * feat(web): right-align chat header actions after tab bar removal With the tabs wrapper gone, chat-header-actions sat flush left because nothing was pushing it across the header. Add margin-left: auto so the history / new-conversation / collapse buttons land at the right edge, matching the design files / index.html tab row's own right-aligned controls. * feat(web): rename board-mode toggle to Comment with comment icon The artifact preview toolbar's board-mode entry was labeled 'Tweaks' with the tweaks icon, which collided with the palette Tweaks button next to it and hid the comment capability behind a generic label. Rename to 'Comment' with the comment icon and switch to the viewer-action class so the button matches the surrounding toolbar items (Edit/Draw) and the coral active state lands on the right surface. * fix(web): pass designTemplates to ProjectView in api-empty-response test The test props for ProjectView were missing the designTemplates prop that was added to Props in #955 (generic skills split). CI's strict typecheck (tsc -b --noEmit) caught it; local runs that hit project references differently did not. Pass an empty SkillSummary array — matches the empty skills fixture for the same reason.
204 lines
7.5 KiB
TypeScript
204 lines
7.5 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
// Polyfill scrollTo for jsdom (not available in jsdom's HTMLElement)
|
|
if (typeof HTMLElement.prototype.scrollTo !== 'function') {
|
|
HTMLElement.prototype.scrollTo = function (
|
|
options?: ScrollToOptions | number,
|
|
_y?: number,
|
|
) {
|
|
if (typeof options === 'object' && options !== null) {
|
|
if (options.top !== undefined) this.scrollTop = options.top;
|
|
if (options.left !== undefined) this.scrollLeft = options.left;
|
|
}
|
|
};
|
|
}
|
|
|
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ChatPane } from '../../src/components/ChatPane';
|
|
import type { ChatMessage } from '../../src/types';
|
|
|
|
// jsdom does not run a layout engine, so scrollHeight/clientHeight/scrollTop
|
|
// are all 0. The scroll-preservation effect derives "near bottom" and the
|
|
// restore target from those, so we install prototype-level getters/setters
|
|
// that route every chat-log read/write through a per-test `geom` object.
|
|
//
|
|
// The earlier shape installed instance-level Object.defineProperty mocks on
|
|
// the *remounted* chat-log only AFTER `await switchTab('Chat')`. Inside that
|
|
// act() the component schedules a rAF that writes scrollTop on the new
|
|
// element; depending on whether jsdom's rAF polyfill flushed before the await
|
|
// resolved, the write either landed on the still-default prototype setter
|
|
// (lost) or the not-yet-installed instance setter (also lost). The instance
|
|
// mock's closure-captured `remountedTop` then served its initial 0 forever
|
|
// and the assertion failed nondeterministically across CI runs without any
|
|
// product-code change. Patching at the prototype level eliminates the race
|
|
// because any chat-log instance React mounts later automatically reads/writes
|
|
// through this single test-controlled geometry.
|
|
type Geom = { scrollHeight: number; clientHeight: number; scrollTop: number };
|
|
let geom: Geom;
|
|
let rafCallbacks: FrameRequestCallback[] = [];
|
|
let savedDescriptors: Record<
|
|
'scrollTop' | 'scrollHeight' | 'clientHeight',
|
|
PropertyDescriptor | undefined
|
|
>;
|
|
|
|
function isChatLog(el: HTMLElement): boolean {
|
|
return typeof el?.classList?.contains === 'function' && el.classList.contains('chat-log');
|
|
}
|
|
|
|
beforeEach(() => {
|
|
geom = { scrollHeight: 0, clientHeight: 0, scrollTop: 0 };
|
|
rafCallbacks = [];
|
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
|
rafCallbacks.push(callback);
|
|
return rafCallbacks.length;
|
|
});
|
|
savedDescriptors = {
|
|
scrollTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'),
|
|
scrollHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight'),
|
|
clientHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight'),
|
|
};
|
|
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
|
|
configurable: true,
|
|
get(this: HTMLElement) {
|
|
return isChatLog(this) ? geom.scrollTop : 0;
|
|
},
|
|
set(this: HTMLElement, v: number) {
|
|
if (isChatLog(this)) geom.scrollTop = v;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
|
|
configurable: true,
|
|
get(this: HTMLElement) {
|
|
return isChatLog(this) ? geom.scrollHeight : 0;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
|
|
configurable: true,
|
|
get(this: HTMLElement) {
|
|
return isChatLog(this) ? geom.clientHeight : 0;
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
rafCallbacks = [];
|
|
for (const key of ['scrollTop', 'scrollHeight', 'clientHeight'] as const) {
|
|
const original = savedDescriptors[key];
|
|
if (original) {
|
|
Object.defineProperty(HTMLElement.prototype, key, original);
|
|
} else {
|
|
delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key];
|
|
}
|
|
}
|
|
});
|
|
|
|
function setGeom(partial: Partial<Geom>) {
|
|
geom = { ...geom, ...partial };
|
|
}
|
|
|
|
function setUserScroll(top: number) {
|
|
geom.scrollTop = top;
|
|
const el = document.querySelector('.chat-log');
|
|
if (el) fireEvent.scroll(el);
|
|
}
|
|
|
|
function chatPaneEl(messages: ChatMessage[], activeConversationId: string | null) {
|
|
return (
|
|
<ChatPane
|
|
messages={messages}
|
|
streaming={false}
|
|
error={null}
|
|
projectId="project-1"
|
|
projectFiles={[]}
|
|
onEnsureProject={async () => 'project-1'}
|
|
onSend={() => {}}
|
|
onStop={() => {}}
|
|
conversations={[]}
|
|
activeConversationId={activeConversationId}
|
|
onSelectConversation={() => {}}
|
|
onDeleteConversation={() => {}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function renderChatPane(messages: ChatMessage[], activeConversationId: string | null = null) {
|
|
return render(chatPaneEl(messages, activeConversationId));
|
|
}
|
|
|
|
const sampleMessages: ChatMessage[] = [
|
|
{ id: 'u1', role: 'user', content: 'first request', createdAt: Date.now() },
|
|
{ id: 'a1', role: 'assistant', content: 'first reply', createdAt: Date.now() },
|
|
{ id: 'u2', role: 'user', content: 'second request', createdAt: Date.now() },
|
|
{ id: 'a2', role: 'assistant', content: 'second reply', createdAt: Date.now() },
|
|
];
|
|
|
|
async function flushFrame() {
|
|
await act(async () => {
|
|
const callbacks = rafCallbacks;
|
|
rafCallbacks = [];
|
|
callbacks.forEach((callback) => callback(performance.now()));
|
|
await Promise.resolve();
|
|
});
|
|
}
|
|
|
|
describe('chat scroll behavior', () => {
|
|
it('does not render the removed comments tab beside chat', () => {
|
|
renderChatPane(sampleMessages);
|
|
|
|
expect(screen.queryByRole('tab', { name: 'Comments' })).toBeNull();
|
|
expect(screen.queryByRole('tab', { name: 'Chat' })).toBeNull();
|
|
});
|
|
|
|
it('does not auto-scroll a short scrollback (~90px above bottom) when new content streams in', async () => {
|
|
setGeom({ scrollHeight: 1000, clientHeight: 400, scrollTop: 0 });
|
|
const { rerender } = render(chatPaneEl(sampleMessages, null));
|
|
// Drain the initial-bottom-scroll rAF queued during the first render,
|
|
// otherwise it fires after our setUserScroll calls and re-pins the
|
|
// ref to true behind the test's back.
|
|
await flushFrame();
|
|
|
|
// User intentionally scrolls 90px up: distance = 1000 - 510 - 400 = 90.
|
|
// That's between the 80px auto-follow cutoff and the 120px jump-button
|
|
// threshold, so the user is deliberately reading slightly earlier
|
|
// output and should not be yanked to the latest message.
|
|
setUserScroll(510);
|
|
|
|
// A new assistant message streams in; scrollHeight grows.
|
|
const streamed: ChatMessage[] = [
|
|
...sampleMessages,
|
|
{ id: 'a3', role: 'assistant', content: 'streaming chunk', createdAt: Date.now() },
|
|
];
|
|
setGeom({ scrollHeight: 1100 });
|
|
await act(async () => {
|
|
rerender(chatPaneEl(streamed, null));
|
|
});
|
|
await flushFrame();
|
|
|
|
// Auto-scroll must respect the user's pause: scrollTop stays where
|
|
// they put it instead of snapping to the new scrollHeight.
|
|
expect(geom.scrollTop).toBe(510);
|
|
});
|
|
|
|
it('lands new conversation at its own bottom when switching conversations', async () => {
|
|
const { rerender } = render(chatPaneEl(sampleMessages, 'conv-A'));
|
|
setGeom({ scrollHeight: 1000, clientHeight: 400, scrollTop: 0 });
|
|
|
|
// User scrolls up in conversation A.
|
|
setUserScroll(150);
|
|
|
|
// The active conversation changes to B. It must land at conversation
|
|
// B's own initial bottom, not at scrollTop: 0 and not at conversation
|
|
// A's saved offset.
|
|
rerender(chatPaneEl(sampleMessages, 'conv-B'));
|
|
await flushFrame();
|
|
|
|
// Saved state was cleared by the activeConversationId-reset effect,
|
|
// so the new conversation lands at its own scrollHeight rather than
|
|
// the browser default 0.
|
|
expect(geom.scrollTop).toBe(1000);
|
|
});
|
|
});
|