mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-07 06:34:10 +08:00
* chore(release): prepare 0.10.0 * [codex] Add same-run retry runtime (#3576) * Add same-run retry runtime * fix(daemon): route retried-start and watchdog stall through retry finalizer Two retryable failure paths bypassed the same-run retry finalizer, so they emitted no run_retry_attempted/run_retry_finished telemetry and could report the fallback retry_final_result: 'not_attempted'. - restartSameRunAfterRetry's catch now routes a failed retried start through finishWithRetryDecision instead of design.runs.finish, so it emits terminal retry telemetry and sets run.retryFinalResult. retryAttemptCount is already 1 there, so decideSafeRunRetry suppresses with attempt_limit_reached and cannot loop. - failForInactivity's no-output stall now routes through the finalizer after surfacing stallPayload. A silent first-token hang classifies as a retryable timeout at first_token_wait, so the same-run retry now recovers it. A new watchdogRetryRestarted guard makes the SIGTERM'd stalled child's close bail early (revoking only its own tool token) so it cannot re-finalize the run or delete the new attempt's event sink (both keyed by the shared runId). - finishWithRetryDecision returns whether it restarted so the watchdog can set the guard. Adds a run-retry-runtime test with a low OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS proving the same-run retry fires for a silent first attempt. * fix(web): avoid duplicate style picker group keys * Fix Design Files and sidebar page layout regressions (#3629) Fixes two web UI layout regressions: - Prevent Design Files name cells from collapsing to near-zero width, which caused generated file and folder names to wrap vertically while leaving empty space to the right. - Hide inactive entry views with display: none so Plugins, Integrations, Design Systems, and Automations no longer reserve hidden Home view height above their page content. Validated with web typecheck, focused web component tests, pnpm guard, and local visual checks. * fix(web): copy run error diagnostics (#3630) * fix(web): route project file links to workspace tabs * fix(web): preserve failed retry attempts * Auto-open produced HTML previews (#3636) * test(e2e): cover assistant project file link routing * feat(web): redesign Design Files panel + pick working directory from Home (#3358) [v0.10.0] (#3618) * feat(web): redesign Design Files panel + pick working directory from Home (#3358) * feat(web): align Design Files panel with Claude reference UI Redesign the Design Files panel into a continuous grouped table that mirrors Claude's own Design Files surface: uppercase gray section bands (Folders / Stylesheets / Scripts / Documents / Images …), flush full-width rows with a colored icon block + bold name + type subtitle + right-aligned timestamp, a clean single-line toolbar (breadcrumbs left, New sketch / Paste / Upload right), a right preview card with a centered Open action and `Modified … · size · EXT` stats, and a static USEFUL INFO footer. Removes the old table/sort-header/kind-filter/pagination/group-toggle chrome and the conflicting legacy `.app .df-*` overrides in routines.css that were trumping the panel's own stylesheet via specificity. Preserves batch select/download/delete, rename, the row context menu, live-artifacts, plugin-folders, drag-upload, and the reloading overlay. Adds the designFiles.* section/kind/useful-info i18n keys across all locales. * feat: pick a working directory from Home before the project exists Let users choose a code folder on the Home screen up front, instead of only being able to replace it after a project is created. The HomeHero composer gains a folder pill (pick / clear); the chosen path is threaded through project creation and applied via the working-dir API once the project id exists. On desktop the working-dir POST is gated behind a host-minted token, so add a `dialog:pick-working-dir` IPC handler that shows the native picker and mints a single-use token bound to the folder, plus a `pickHostWorkingDir()` bridge + types/normalizer in `@open-design/host`. The renderer feature-detects the bridge and falls back to the web folder dialog (no token) otherwise. Native dialogs now allow creating a new folder inline (`createDirectory` / `ShowNewFolderButton`), and macOS uses `choose folder` so the panel reliably takes key focus. Wire the WorkingDirPill into the project composer footer and surface a reloading state + breadcrumb root label to the Design Files panel while a replace reindexes. Drop the WorkingDirPill "recent directories" localStorage list in favor of a single Replace action. * fix(web): preserve staged Home attachments when picking a working dir Move the working-dir handoff ahead of the initial upload so staged attachments land in the external folder instead of the temporary managed root, and fall back to the browser folder dialog when the desktop host build does not expose pickWorkingDir. * fix(web): surface host pick errors and normalize Windows dir basenames When the desktop host is present but cannot mint a working-dir token, surface the error instead of falling back to the browser folder dialog — the tokenless raw path would be rejected by the desktop auth gate as a late create-time failure. Only the pure web path (no token gate) keeps the dialog fallback. Also split the Home pill basename on both path separators so Windows paths show the folder name, not the full path. * fix(web,desktop): harden Home working-dir handoff against auth/token failures Run the desktop-auth handshake before minting the Home working-dir token so a missed startup registration fails up front instead of as a rejected deferred POST. Surface a create-time error toast when the working-dir handoff fails (expired ~60s token or daemon rejection) instead of silently leaving the project in the managed root while the user believes their folder was applied. * fix(web): abort create on failed working-dir handoff and always show root crumb When the pre-create working-dir handoff fails, short-circuit the rest of the Home submit path (skip the staged-attachment upload and auto-send) so the first run cannot proceed against the managed `.od/projects/<id>` tree the user did not choose, and reword the error toast accordingly. Always render the Design Files root breadcrumb, falling back to the `designFiles.crumbs` label when no rootDirName exists, so the toolbar is not blank on the default managed-storage view. Adds regressions for the short-circuit and default-root crumb cases. Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code) * fix(web): normalize Windows separators in WorkingDirPill basename shortPath() split only on '/', so a Windows working dir like C:\work\repo rendered as the full absolute path in the footer pill instead of the folder name. Split on both separators to match HomeHero/ProjectView, and add a regression covering the backslash path. Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code) --------- Co-authored-by: qiongyu1999 <2694684348@qq.com> (cherry picked from commit6408937564) * fix(web): create files in the open Design Files folder, not the root (#3358 regression) (#3623) #3358's panel redesign dropped the `onCurrentDirChange` handoff, so `FileWorkspace.uploadDir` stayed at the project root while `DesignFilesPanel` tracked the viewed folder only in local state. After navigating into a subfolder, Upload / Paste / New sketch / dropped files all created at the project root instead of that folder. Restore the sync: `DesignFilesPanel` reports its `currentDir` via a re-added `onCurrentDirChange` prop, and `FileWorkspace` feeds it back into `uploadDir`. Regression in DesignFilesPanel.test.tsx: navigating into a folder reports the nested dir so the parent creates files there. (cherry picked from commit831781141f) * fix(web): keep empty persisted folders visible in Design Files #3358's redesign derived the folder list from file paths only and dropped the `folders` prop, so an empty persisted folder (user-created, or an empty dir in an imported working directory) vanished — it couldn't be opened or deleted until a file lived inside it. Thread the fetched `folders` back into DesignFilesPanel and merge them into `dirsAtCurrentDir`; don't show the project-empty state when folders exist; and treat a persisted folder as a valid currentDir so navigating into an empty one doesn't bounce to the root. FileWorkspace passes `folders={projectFolders}` (already fetched). Regressions: an empty persisted folder shows at the root, and a nested empty folder is navigable. * fix(web): reset project folders when switching projects With persisted folders now rendered, FileWorkspace kept the previous project's folder list until the async fetchProjectFolders resolved, so switching to a different (empty) project briefly showed the old folders and suppressed the new project's empty state. Clear projectFolders synchronously on projectId change, before the fetch. Regression in FileWorkspace.test.tsx: rerendering with a new projectId while the new folder fetch is pending drops the previous project's folders. (cherry picked from commit36e336f312) --------- Co-authored-by: elihahah666 <template@refly.ai> Co-authored-by: qiongyu1999 <2694684348@qq.com> * fix(web): scope app file links to current project Generated-By: looper 0.9.2 (runner=fixer, agent=codex) * feat: stream agent questions token-by-token (#3582) [v0.10.0] (#3637) * feat: stream agent questions token-by-token (#3582) * feat(web): stream AskUserQuestion and discovery questions token-by-token Render both agent question surfaces as they generate instead of popping in only once the whole payload finishes. - AskUserQuestion (Claude tool): builds on the live tool-input streaming landed in #3382. When a still-streaming tool's name is AskUserQuestion, render a read-only StreamingAskUserQuestionCard that grows its questions and options from the repaired JSON prefix; the interactive (persisted) card takes over once the full tool_use lands and the id leaves liveToolInput. - <question-form> discovery (any agent, incl. Codex): the streaming parser now repairs the truncated prefix so a question shows the moment its label exists and its options grow in one at a time, instead of waiting for each question object to fully close. The Questions panel UX (skip countdown, auto-continue, reveal) is unchanged. Shared repairJsonPrefix/parsePartialJson live in runtime/partial-json.ts. No daemon, contract, or provider changes — this reuses #3382's tool_input_delta / liveToolInput plumbing. * fix(web): harden streaming <question-form> parser (review follow-ups) Two merge-safe follow-ups from review on parsePartialQuestionForm: - Derive form-level id/title/description from the parsed top-level object instead of regex-scanning the whole partial body, so a nested question or option id/description can no longer masquerade as the form's own. form.id keys the live Questions panel (ProjectView), so a mid-stream identity change would remount it. Removes extractJsonStringField. - Strip a (possibly partial) trailing ``` fence so the live preview keeps streaming in the gap between the fenced JSON body closing and the </question-form> close tag arriving, instead of dropping back to empty. Both covered by regression tests in question-form.test.ts. * fix(web): preserve submitLabel + string-safe fence trim in streaming form parser Second round of review follow-ups on parsePartialQuestionForm: - Carry top-level submitLabel through the streaming preview, mirroring tryParseForm, so a custom CTA label doesn't flicker in only once the close tag arrives. - Only strip the trailing ``` fence when those backticks are the closing wrapper, not content of a JSON string value still being typed — a label like "Use ```" mid-stream kept losing its backticks. The trim now checks it isn't inside an open JSON string first. Both covered by regression tests in question-form.test.ts. * fix(web): honest status badge on WebFetch/WebSearch cards WebFetchCard / WebSearchCard hard-coded a green check badge regardless of the real tool state, so a slow or failed fetch/search rendered as false-green. Thread result / runStreaming / runSucceeded through both and reuse ResultBadge (as the other tool cards do) so the badge reflects the actual outcome. Pre-existing on main (only surfaced in this PR's changed range because the new streaming card additions shifted these lines down); folded in here since it's a one-line-per-card fix in a file this PR already touches. * fix(web): keep partial-JSON repair stable across a dangling string escape repairJsonPrefix() appended a closing quote even when the stream stopped on an incomplete escape inside an open string, leaving invalid JSON: `{"a":"x\` became `{"a":"x\"` (the quote got escaped) and `{"a":"x\u` became an invalid `\uXXXX`. parsePartialJson() then returned null, collapsing the live AskUserQuestion / question-form preview to its empty frame mid-stream whenever a prompt mentioned a Windows path, regex, or escaped quote. Now neutralize a trailing incomplete escape before closing the string: drop a partial `\uXXXX` (0-3 hex) and a lone (odd-run) trailing backslash. Completed escapes and even backslash pairs (e.g. `C:\\Users\\`) are preserved. Regression tests in ask-user-question.test.ts cover the repair cases and a path-like prompt that keeps its last visible content. * fix(web): trim unfinished scalar values in partial-JSON repair repairJsonPrefix() only trimmed dangling commas / empty `"key":`, so a delta that stopped mid-scalar left invalid JSON: `{"…","multiSelect":f` closed to `{…:f}` and failed to parse, collapsing the live preview whenever a boolean, null, or number token split across deltas. The cleanup loop now also trims a partial `true`/`false`/`null` and a number cut on `.`/`e`/sign, leaving the `"key":` / `,` / `[` boundary for the next iteration. Complete literals (`true`, `12`, `1.5`, `1e3`) and string values that merely start like a literal are not prefixes of these patterns, so they survive. Regressions added in both the AUQ and question-form suites (a boolean split across deltas keeps already-visible questions) plus repair-level unit tests. * fix(web): stable preview question ids while a form streams shapeStreamingQuestions surfaced a question as soon as its label existed, but mapRawQuestion falls back to q${index+1} until the real id streams — so a question that arrives label-first and gains its canonical id later would change id mid-stream. The Questions panel stays editable while building and keys fields / answers off q.id, so that id change remounts the field and orphans whatever the user already picked. Pin the preview id to the array index for the whole streaming phase (questions only append once their label exists, so the index is stable); the final non-preview parse still uses the real id. Regression in question-form.test.ts: label-first then id-later keeps id q1. * fix(web): preview question ids match the final parse (no orphaned answers) The previous index-pin kept ids stable *within* the preview but still differed from the final canonical id, so an answer entered while the panel was building got stranded at the preview→final swap (field re-keys from `q1` to `platform`, reads nothing). Surface a streaming question only once its canonical id is determinable instead: use the streamed `id`; or, for an already-closed (not in-flight) object with no id, the `q${index+1}` fallback that the final parse will also assign; hold the in-flight object until it gains an id. Preview id now always equals the final id. Updated/added regressions in question-form.test.ts: a label-first object is held until its id is determinable, and preview ids match splitOnQuestionForms. * fix(web): stable positional keys in the streaming AskUserQuestion card The streaming card keyed fields on q.question and options on opt.label, but those strings grow token-by-token, so every delta changed the key and remounted the subtree — replaying the reveal animation on each token instead of mounting once. Switch to positional keys (questionIndex / questionIndex:optionIndex); questions and options only append during streaming, so the index is stable and each node updates its text in place. Regression: StreamingAskUserQuestionCard.test.tsx asserts the field/option DOM nodes are reused (===) across successive grown prefixes. * fix(web): stream a closed no-id question before the close tag The previous gate held *every* last no-id question as if in-flight, but a last object whose braces have already streamed has a final fallback id (q${index+1}) and should appear immediately — holding it broke the "question shows the moment its label exists" behavior for forms that omit explicit ids. Distinguish closed vs still-open via a string-aware balanced-object count: surface a question once its object has closed (id final) or it already carries an `id` (stable); keep holding only an in-flight object with no id yet. Regressions: a single closed no-id question streams pre-close-tag (id q1), while the same content with an unclosed object stays held. * fix(web): suppress hedging fallback text while a live AskUserQuestion streams suppressAskUserQuestionFallbackText ran on the persisted blocks before the liveBlocks pass, so during streaming (no persisted tool_use yet) seenAskUser Question never flipped and any duplicate hedging markdown survived into persistedBlocks while the live card was appended after — the exact duplicate-question state the card removes, just earlier in the turn. Seed the suppressor with whether a live AskUserQuestion is present in liveToolInput so it fires during the streaming phase too. Regression in AssistantMessage.test.tsx: fallback prose coexisting with a live AskUserQuestion in liveToolInput is suppressed, while normal prose without a live AUQ is kept. * fix(web): order-aware fallback suppression for live AskUserQuestion The previous fix seeded the suppressor with "a live AskUserQuestion exists", which dropped ALL text blocks in the turn — including legitimate preamble ("I need one clarification:") that precedes the tool call, not just duplicate hedging. Make it order-aware instead: suppress text only after an AskUserQuestion in block order, and have the suppressor recognize a live (streaming) AskUserQuestion live-tool block the same way it recognizes a settled tool-group. Preamble before the card is kept; hedging after it is dropped — matching the settled-case semantics. Regression updated in AssistantMessage.test.tsx: intro preamble coexisting with a live AskUserQuestion in liveToolInput is preserved. * fix(web): place the live AskUserQuestion at its real stream position The order-aware pass still appended the live AUQ block after all persisted blocks, so hedging text the model emits *after* starting the tool call (which is already in events, hence before the appended block) was kept. Carry the event index the tool started at (`seq`, recorded in liveToolInput when the first input_json_delta arrives) and split the events around it: blocks before `seq` are preamble, the live AUQ block goes next, blocks after `seq` are hedging. Suppression then keeps the preamble and drops the trailing hedging, exactly matching the settled tool_use case. Regression in AssistantMessage.test.tsx: with the tool starting after the preamble (seq=1), preamble before the card is preserved and hedging after it is dropped. * fix(web): account for buffered preamble when pinning live-tool seq `seq` was read from `latestAssistantMsg.events.length`, but preamble text goes through `appendTextEvent` and isn't committed to `events` until the next rAF/250ms flush. When intro prose and the first `tool_input_delta` arrive in the same burst, that read undercounts by one, so the live AskUserQuestion was positioned before the preamble and suppression dropped it as hedging. The buffer now exposes `hasPendingText()`, and seq adds 1 for a still-pending preamble chunk (it commits as one text event just before the tool position). Regression in buffered-text-pending.test.tsx: appended text is reported pending and absent from events until flush, then commits as one event. * fix(web): re-render the streaming row on live tool-input deltas AssistantMessage is memoized with a prop allowlist that omitted liveToolInput, so a burst of pure tool_input_delta traffic (which only changes liveToolInput, not message) was swallowed by the comparator — the live AskUserQuestion card / code box wouldn't grow until some unrelated change, usually the final tool_use. Add liveToolInput to ASSISTANT_MESSAGE_COMPARED_PROPS, and have ChatPane pass it only to the streaming row (undefined elsewhere) so just that row re-renders per delta, not all N messages. Also widens ChatPane's liveToolInput type with the optional seq field. Regression in AssistantMessage.test.tsx: with the same message object, changing only liveToolInput re-renders and grows the card prompt. (cherry picked from commit6e10fa4852) * fix(web): keep the streaming question-form id stable (no churn) `parsePartialQuestionForm` derived the form `id` from the streaming body `id` token, but that arrives char-by-char and `parsePartialJson` repairs the open string, so `form.id` churned (`"d"` → `"di"` → …). `id` keys the live, still-editable Questions panel (ProjectView's questionFormKey, QuestionForm's data-form-id / input names), so each partial-id change remounted the panel and discarded in-progress answers. Derive the preview id from the open-tag attr (complete the instant the tag streams) or the stable default instead; the final parse (tryParseForm, after the close tag) still adopts the real body id. This also keeps the earlier nested-id-masquerade guarantee (no body scan). Follow-up to #3582. Regression: the form id stays `discovery` while the body id token streams in. (cherry picked from commit353e4a64ac) * fix(web): adopt the question-form body id once complete (no preview→final remount) Building on the char-churn fix: when a form omits the open-tag id attribute but carries a body id, the preview used 'discovery' and the final parse adopted the body id — a single remount at the close tag that could drop answers. Adopt the body id as soon as its string literal is terminated (depth-1, nested ids ignored); that value equals what tryParseForm assigns, so preview id == final id and the panel never remounts on the swap. The id settles early (it's a leading field), before any question is answerable. Regressions: a partial body id is not adopted (no churn); a complete body id is adopted and matches splitOnQuestionForms' final id; nested question ids are ignored. (cherry picked from commit9576f98f56) * fix(web): reset project folders during render, not in an effect (#3649) mrcfps flagged that the effect-based folder reset still let the new DesignFilesPanel (keyed by projectId) mount once with the previous project's folders before the effect cleared them — briefly suppressing the new project's empty state, the exact regression this follow-up removes. Move the reset into the render phase (adjust state during render via a projectId ref, mirroring the designFilesNav reset), so the new panel never observes stale folders. A stable EMPTY_PROJECT_FOLDERS constant keeps the render-phase setState idempotent. Tightens the regression: a wrapper records the folders prop on every DesignFilesPanel render, and the test asserts no render for the new project ever sees a non-empty folder list — the pre-paint frame RTL's post-rerender DOM check can't observe. Red on the effect-based reset, green here. (cherry picked from commit3e26d81f4a) * Fix stale chat anchor spacer on resend * feat(web): add next-step actions after artifact generation (#3635) Backports #3439 to release/v0.10.0. Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local> * fix(conversation-forking): fork from the in-memory snapshot so an unpersisted turn no longer silently fails (#3648) (#3656) "Fork from here" creates a new conversation by asking the daemon to copy the source conversation's messages up to the fork point, looked up in SQLite by `forkAfterMessageId`. When the fork point is an assistant turn whose run errored or had its connection reset before the message was written to the database (common with AMR/ACP "Connection reset by server"), that id is not in the DB. The daemon returned 404 "fork message not found", `createConversation` turned the non-OK response into `null`, and `handleForkFromMessage` threw into a quiet error state — so no new conversation opened and the user never switched into the fork. The page just flashed and stayed put. Make fork copy what the user is actually looking at: the chat "Fork" action now sends the in-memory messages slice (up to and including the fork point) as `seedMessages`, and the daemon prefers that snapshot over reading the source conversation from the DB by id. The Side Chat path (no `seedMessages`) is unchanged and still seeds from `seedFromConversationId`. Red spec: apps/daemon/tests/projects-routes.test.ts "forks from a client-supplied snapshot when the fork point was never persisted" — 404 on main, 200 on this branch. * fix(home,daemon): keep mode card on-screen and let plugin authoring pause on a question-form (backport #3640 to v0.10.0) (#3659) * fix(home,daemon): keep mode card on-screen and let plugin authoring pause on a question-form Two release/v0.10.0 firefight fixes: - Home Design Agent description card: the mode toggle's popover card is only shown on the home hero and anchors upward, so at its default 360px it crowds (and on smaller windows clips) the page header. Cap its height on the home hero so it always clears the top chrome and scrolls for the overflow. - Plugin authoring 'ended before generating the required generated-plugin artifacts' error: the od-plugin-authoring turn-1 flow emits a <question-form> to collect the brief, then stops and waits for the user. That turn legitimately closes with code 0 and no artifacts yet, but the missing-artifacts guard treated it as a failure. Track whether the agent emitted a clarifying <question-form> in its streamed text and skip the guard in that case. Red spec: chat-route.test.ts 'does not fail plugin authoring when the turn-1 reply is a clarifying question-form awaiting the brief' (red on main, green on branch). (cherry picked from commit1eee8b71ce) * fix(daemon): recognize <ask-question> alias in plugin-authoring clarification guard The web form parser (apps/web/src/artifacts/question-form.ts) accepts both <question-form> and its <ask-question> alias as a valid brief form. The plugin-authoring missing-artifacts guard only matched the canonical tag, so a turn-1 reply using the alias still rendered a valid form in the UI but fell through to the 'ended before generating…' failure. Mirror the parser's accepted tag set in the daemon guard and add a regression test for the alias path. Addresses Looper review feedback on #3640. (cherry picked from commit80033dcdbe) * fix(daemon): gate plugin-authoring clarification carve-out on a renderable form body The previous carve-out matched only the question-form/ask-question opening tag, so a malformed (non-JSON) body — which the web parser keeps as raw prose with no rendered form card — would still suppress the missing-artifacts failure, turning a hard failure into a false success. Mirror the parser's minimal renderable contract in a daemon-local helper (emittedRenderableQuestionForm): a closed question-form/ask-question block whose body parses as JSON to an object with a non-empty questions array. Buffer the run's visible text and validate at close. Documented as a deliberate mirror of apps/web/src/artifacts/question-form.ts since the app boundary forbids importing it; promote to packages/contracts if they drift. Tests: question-form and ask-question fixtures now use valid JSON bodies, plus a new regression pinning that a non-renderable (non-JSON) form body still fails. Addresses Looper review feedback on #3640. (cherry picked from commitba28f07edd) * fix(daemon): keep question-form close-tag scan Unicode-safe in the daemon mirror The mirrored renderable-form check lowercased the whole buffer before indexing the close tag. Some code points expand under toLowerCase ('İ' -> 'i̇'), shifting every offset after them, so closeIdx desynced from openEnd (original-string coordinate) and the JSON body slice was corrupted — failing a valid form when a casing-expanding char preceded it. Mirror the web parser's findCloseTag: scan char-by-char and lowercase each fixed-length candidate slice, returning an index in the original-string space. Added a daemon regression with an 'İ' preamble before a valid form. Addresses Looper review feedback on #3640. (cherry picked from commit3207d1dc2b) * fix(web): keep question forms in questions panel (#3661) * feat(web): collapsible entry rail + quiet new-user templates + icon-only topbar chips (#3617) (#3666) * feat(web): collapsible entry rail + quiet new-user templates + icon-only topbar chips Rework the entry view to keep new users focused on the hero composer: - Nav rail collapses to a single topbar toggle by default and docks as an overlay on demand; a hover-reveal collapse button on the logo folds it back. - For users with no projects, the Community templates gallery starts hidden behind a faint bottom hint; an upward scroll gesture or click smoothly reveals it, with a pinned collapse pill to fold it away again. - Collapse the Discord / model-switcher / Use-everywhere topbar chips to icon-only with hover tooltips; the GitHub Star chip stays a full pill. * test(e2e): expand collapsed nav rail before driving rail destinations The entry nav rail now starts collapsed, so the UI smoke tests that click `entry-nav-*` items (or assert the rail/logo is visible) timed out. Add an idempotent `ensureRailOpen` helper and call it before rail nav interactions across the e2e/ui suite; adjust the two rail-structure assertions to expand first. * test(e2e): reach home via Home nav and expand rail for help launcher The logo now doubles as a hover-to-collapse control, so clicking it to return home is intercepted by the collapse overlay — route via the explicit Home nav item instead. Also expand the rail before opening the rail-footer help menu, and guard the execution-pill nav loop with ensureRailOpen. * fix(web): make collapsed rail inert and restore Discord chip title Address review feedback: - The collapsed nav rail kept its logo + nav buttons mounted and focusable, so a fresh Tab on the home view landed on invisible rail controls before the visible toggle/hero. Mark the rail `inert` while collapsed so its descendants leave the keyboard tab order and pointer flow; add a keyboard regression test. - Restore the Discord chip's native `title` alongside `data-tooltip` so the existing topbar contract test (entry-topbar.test.ts) keeps passing. * fix(web): make collapsed templates gallery inert and keep rail foldable on touch Address second review pass: - The collapsed new-user templates gallery was only aria-hidden + height-clipped, so its still-mounted buttons/tabs/search stayed in the keyboard tab order. Mark the gallery body `inert` until revealed (deps include `enabled` so it applies once the async-mounted body appears); add a keyboard regression test. - On coarse-pointer / non-hover devices the hover-only collapse button plus the display:none topbar toggle could strand the rail open. Keep the collapse control visible + tappable under `@media (hover: none)`; add a CDP-emulated touch regression test. * fix(web): keep inline switcher chip accessible name in icon-only topbar --------- Co-authored-by: elihahah666 <template@refly.ai> Co-authored-by: qiongyu1999 <2694684348@qq.com> * fix(web): stop early analytics events shipping app_version=0.0.0 (#3609) (#3614) useAppVersion() reads the daemon-pinned version from /api/version at runtime, but PostHog's app_version rode solely on the register() super-property, which races that fetch. High-frequency early events (page_view above all) captured before the version resolved shipped with app_version='0.0.0' permanently — the dashboards showed 0.0.0 on every build after the runtime-fetch approach landed. resolveAppVersionForCapture() awaits a shared single-flight /api/version fetch before every capture path (client init, super-property register, track, consent re-init) and stamps app_version/ui_version onto each event explicitly, so no event ships with the placeholder once the version is knowable. The hook still returns the placeholder for first paint. Red spec: tests/analytics-app-version.test.tsx gains resolveAppVersionForCapture cases (resolves real version before capture; no refetch when already real). * feat(composer): reconcile context with prompt + per-connector brand color (#3625) (#3670) * feat(composer): reconcile context with prompt + per-connector brand color Two increments on top of the Lexical composer (#3516): - Filter contextPlugins/contextMcpServers/contextConnectors at submit time by whether the entity's @mention still survives in the prompt text. When a user deletes a mention pill, that plugin/MCP/connector is no longer sent to the agent. Adds mentionTokenPresent() to inlineMentions. - Give connector mention pills a per-connector brand color (Notion black, Figma purple, etc.) by setting an inline --m-hue in MentionNode.createDOM, reusing the existing CSS hue machinery instead of the shared connector green. Adds a curated brand-color map with a hashed fallback. * fix(composer): keep mention context when punctuation trails the pill mentionTokenPresent() reused the parser's strict isMentionRightBoundary(), so a still-visible atomic pill serialized as `@Slack,` was treated as deleted and dropped from contextPlugins/contextMcpServers/contextConnectors at submit time. Add a submit-time boundary that also accepts trailing clause/sentence punctuation, leaving the parser invariant untouched, plus a HomeView regression for the punctuation case. Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code) * fix(composer): make connector pill brand hue theme-aware Near-black curated brand colors (Notion, GitHub, Slack) rendered the connector mention pill text as dark-on-dark in the app's dark theme, since chat.css derives the text as color-mix(--m-hue 72%, --text) and --text is light in dark mode. connectorBrandColor now takes the live theme and lightens hues below a luminance floor so they stay legible, while bright brands (Figma, Linear) pass through unchanged. Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code) * fix(composer): make connector pill hue follow live theme switches Recompute a mounted connector pill's brand --m-hue when <html data-theme> or the OS prefers-color-scheme changes, not only on node create/update, so a pill inserted in light mode no longer keeps its near-black hue (and dark-on-dark text) after a live switch to dark mode. Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code) * feat(composer): collapse footer tools into one "+" menu; drop duplicate context badges - Replace the separate "@" context-trigger and paperclip attach buttons with a single "+" button. Its popup offers Attach files plus Connectors/Plugins/MCP right-side flyout submenus (Plugins/MCP searchable + scrollable). Picking an entry inserts the inline @mention pill via the existing pickX handlers; "Add …" rows route to the plugins / integrations views. - Stop rendering the top plugin/connector/MCP context-badge row — those now live as inline mention pills in the composer, so the badges were duplicates. Staged file chips still render there. showActiveContextRow no longer keys off the selected*Contexts arrays. - New i18n keys (addMenu/addPlugin/addConnectors/addMcp/noPlugins/noMcp/ noConnectors) in types.ts + en + zh-CN; other locales inherit via ...en. * fix(composer): repair collapsed-menu test selector and complete addMenu i18n Update the HomeView context-picker test to open the plus menu before asserting the attach affordance, since the collapsed footer now renders home-hero-plus-attach only after home-hero-plus-trigger is clicked. Add the seven home-hero add-menu keys (addMenu, addPlugin, addConnectors, addMcp, noPlugins, noMcp, noConnectors) to every locale beyond en/zh-CN so the i18n contract holds across all 18 locale files. Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code) * test(web): drop assertions on removed context-badge row The PR removed the duplicate plugin/connector/MCP context-badge row (context now renders as inline @mention pills), but four HomeView tests still asserted the dropped `home-hero-context-plugin-*` testids and went red. Re-anchor them to behavior that survives: the submit payload's `contextPlugins`/prompt-pill text for picker flows, and the active context row's resolved-count label for empty-prompt "Use" handoffs. Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code) * test(web): align entry-chrome e2e with collapsed footer menu The footer-collapse change removed the always-visible home-hero-attach control and the plugin/MCP/connector context-badge row, but the entry chrome Playwright suite still asserted those removed surfaces, failing Browser tests (and the Validate workspace aggregate). - Assert home-hero-plus-trigger instead of the removed home-hero-attach. - Drop the home-hero-context-plugin-* badge assertions; plugin context no longer renders a visible badge. Coverage is preserved by the existing prompt-text and submit-payload (pluginId) assertions. * fix(composer): exclude inline-mentioned contexts from active row; reset plus-menu query per submenu Gate the Home active context row on contexts that have no inline @mention representation, so selecting only an inline-mentioned connector no longer mounts an empty row above the editor. Reset the shared plus-menu search query when the active submenu changes so a stale plugin search no longer filters the MCP list into a false "No MCP servers" empty state. Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code) * fix(composer): keep context-only Use selections in submit payload The submit-time reconciliation dropped every selected context that lacked an inline @mention token, which silently discarded contexts staged through the plain Use action (it never writes a token). The agent then received no contextPlugins even though the user explicitly chose the plugin as context. Tag each selection with inlineBacked: contexts inserted as @mention pills are only sent while their token survives in the prompt; context-only Use selections stay in the payload until explicitly cleared. The active context row likewise counts only context-only selections, so an inline-mentioned-only pick no longer mounts an empty row. Adds a regression covering the direct Use submit path. * fix(composer): render removable chips for context-only Use selections Context-only selections (staged via the plain Use action, no inline @mention pill) are kept in the submit payload, but the home composer no longer rendered any chip for them, so they were invisible and unremovable — a kept context would silently drift into later freeform prompts. Render a removable chip in the active context row for each context-only plugin/MCP/connector selection, wired to the existing remove handlers. Adds a regression that stages a plain Use context, clears it via the chip, and asserts the submit payload no longer includes it. --------- Co-authored-by: elihahah666 <template@refly.ai> Co-authored-by: qiongyu1999 <2694684348@qq.com> * fix(web): stop conversation list flashing "0 msg" while switching chats (#3663) (#3672) * fix(web): stop conversation list flashing "0 msg" while switching chats The chat tab's conversation list derives the active conversation's message count from the live `messages` array. Switching conversations resets that array to [] while the new conversation's history is fetched, so the active row rendered a phantom "0 msg" for the duration of the load (and stayed at 0 if the load failed) instead of its real count. Trust `messages.length` for the active conversation only once the live array actually reflects it (`messagesConversationId === activeConversationId`); otherwise fall back to the persisted server `messageCount`. The live count is still used during streaming so the active row stays fresh as turns arrive. * fix(web): wire messagesConversationId for the other ChatPane mounts SideChatTab and DesignSystemFlow also render the conversation list but did not pass the new marker, so their active row fell back to the persisted count and lost live-count freshness. Both own a single loaded conversation whose messages always reflect the active id, so pass that id through to keep the active row's live count authoritative (restores pre-change behavior). ProjectView still passes its real messagesConversationId (null during the switch/load window). * fix(web): keep secondary ChatPane mounts on the persisted message count The SideChatTab and DesignSystemFlow mounts both have loader windows where the live `messages` array does not reflect the active conversation — useConversationChat resets to [] before a load, and DesignSystemFlow's loader does not retag projectChatMessages on switch. Marking the live array as authoritative there would reintroduce the same phantom/stale count this PR removes. Omit messagesConversationId for those mounts so their list uses the stable persisted conversation.messageCount; only ProjectView, which tracks the marker precisely, opts into the live count. * fix(web): bottom-anchor the chat transcript so short and forked conversations don't strand at the top (#3669) (#3673) A short conversation — most visibly a fresh "Fork from here" branch — rendered its messages stuck to the top of the chat log with a large empty area below, instead of resting against the composer like a normal chat. Two causes: 1. `.chat-log` is a flex column with no vertical anchoring, so when the content is shorter than the viewport the messages sit at the top (~530px of dead space below). 2. The anchor-to-top reserve (`.chat-log-tail-spacer`, sized while a reply streams so the user's turn can reach the top) never collapsed once the turn finished. A short completed turn was left pinned at the top above ~540px of reserved blank. Fix: - Bottom-anchor the transcript with `margin-top: auto` on the first row. When content is shorter than the viewport the auto margin absorbs the slack and the messages rest against the composer; when it outgrows the viewport the margin resolves to 0 and the log scrolls normally with the top fully reachable. (Deliberately not `justify-content: flex-end`, which clips overflowing content above the scroll range.) - Collapse the tail spacer when streaming ends, and reset it (plus the anchor flags) on conversation switch, so a finished or freshly opened short conversation settles cleanly instead of inheriting a stale reserve. Anchor-to-top during an active turn is unchanged. Verified live (Playwright, local daemon+web): fresh short conversation last-message-to- bottom gap 532px -> 20px; completed short turn tail spacer 549px -> 0; long conversation still scrolls and reaches the top (scrollTop 0, first row at +18px). * fix(web): flip deck preview to a queued send's marked slide (#3668) (#3681) * fix(web): flip deck preview to a queued send's marked slide When several edits are queued against marked elements on different deck slides, the preview stayed parked on slide 1 while the agent drained the queue — the user couldn't see each edit land in context. The mark's slide index was already captured into the comment snapshot and target, but it was dropped when the snapshot became a ChatCommentAttachment, so nothing downstream knew which slide a queued send touched. Carry `slideIndex` through the contract and both attachment builders, then have ProjectView arm a `slideNavRequest` (mirroring `shareRequest`) the moment a queued send starts processing. FileWorkspace activates the deck tab if it is open and FileViewer consumes the nonce once to flip the preview via the existing `od:slide` go bridge, writing the cached slide state first so a freshly-mounted iframe restores to the same slide on load. No-op for plain prompts, free pins, or marks without a slide index. * fix(web): keep deck slide-nav consume-once across viewer remounts The consumed-nonce guard lived in a per-mount ref, so leaving the deck tab and returning remounted HtmlViewer, reset the ref, and replayed the still-live slideNavRequest — yanking the preview back to the queued slide and clobbering manual navigation. Move the guard into shouldConsumeSlideNav, keyed by preview state key outside the component, so "consume once" survives remounts. Add a unit spec covering first-handling, fresh-nonce, and per-file isolation. * fix(web): only flip a deck whose tab was open when the queued send started slideNavRequest stayed live in parent state, so a request for a deck that was closed at processing-start would resurface and jump the preview when the user opened that file by hand much later — not the follow-along behavior the feature promises. Decide deliverability once, at fire time: FileWorkspace marks the nonce deliverable only when the target deck is already an open tab, and the viewer is fed the request only for that deliverable nonce on the active file. A closed deck never becomes deliverable, so opening it later cannot jump. Pure helpers isSlideNavDeliverableNow / deliverableSlideNavForActiveFile carry the decision, with specs for the closed-deck-opened-later case. * Fix URL preview image export capture * fix(web): keep generation progress card on the design-files tab (#3671) (#3680) * fix(web): keep generation progress card on the design-files tab (#3671) The right-side preview pane showed the empty "Creations will appear here" file list while an agent was actively generating, instead of the generation progress card with its understand/generate/prepare steps. PR #3516 (Lexical composer rewrite) added an `activeTab !== DESIGN_FILES_TAB` clause to `showGenerationPreview`. The design-files tab is the default landing tab, so on a fresh project the in-flight generation preview was suppressed and the idle empty state won the surface. Before #3516 the preview branch rendered ahead of the design-files branch with no tab guard. Drop the clause so the progress card again takes priority over the empty file list. `buildGenerationPreviewState` returns null once a previewable artifact exists, so a finished artifact is never hidden behind the card. Adds a red-spec FileWorkspace test that renders a generating run on the design-files tab and asserts the generation-preview stage is shown. * fix(web): scope generation-preview override to the empty design-files tab Address review: dropping the DESIGN_FILES_TAB guard outright let the generation card win for every in-flight run on the design-files tab, hiding the file browser of a populated project until a previewable artifact existed. Scope the override to the truly empty tab, mirroring DesignFilesPanel's own empty-state gate (no files, no live artifacts, no folders), so a populated project keeps browsing while generating. Adds a regression test that a populated design-files tab still renders file rows while a run is active. * feat(web): simplify Home composer (drop plugin-inputs form & example pill, move Design Agent next to Send) (#3645) (#3683) * feat(web): simplify Home composer (drop plugin-inputs form & example pill, move Design Agent next to Send) - Remove the inline plugin-inputs form (ARTIFACT KIND / AUDIENCE / TEMPLATE) from the Home composer; required inputs no longer gate Send (defaults still flow to the backend). - Remove the dismissible "selected example" pill above the editor; picking an example still seeds the prompt. - Move the Design Agent toggle from the footer-left group to sit directly left of the Send button. * fix(web): keep client-side required-input gate on Home composer Dropping the inline plugin-inputs form left pluginInputsAreValid() always returning true, so a required field with no default that the user blanks out in an editable Home scenario kept Send enabled and only failed at click-time apply with a generic "Failed to apply …" error (the daemon's validateInputs rejects missing required inputs). Restore a client-side gate that mirrors the daemon's required-input rule and names the missing field in the submit error, so these Home flows fail fast. Extracts the pure check into utils/pluginRequiredInputs with unit coverage. * fix(web): drop example from active-row mount condition With the example pill removed from the Home composer active row, keeping selectedPromptExample in showActiveContextRow mounted an empty .home-hero__active row when an example was picked with no other active context. Remove it from the condition so the row only renders when it has visible content. --------- Co-authored-by: elihahah666 <template@refly.ai> Co-authored-by: qiongyu1999 <2694684348@qq.com> * ci(release): throttle the per-push nightly build instead of debouncing it (#3682) (#3687) `notify-release-feishu` builds a full nightly (via release-stable, channel=nightly) and posts a Feishu card on every push to `release/**`. Its concurrency used `cancel-in-progress: true`, so a newer push cancelled the in-flight build. When a release branch is busy — pushes spaced closer than a full cross-platform build takes — every build was cancelled by the next push and no nightly was ever cut. (Observed on release/v0.10.0: a run of pushes all cancelled, the last successful nightly was hours old.) Switch to `cancel-in-progress: false` (throttle): the running build finishes, and pushes that arrive during it coalesce — GitHub keeps the in-progress run plus only the newest pending run, cancelling older pending ones. A busy release branch now always cuts a nightly and converges on the latest commit, with at most one build running plus one queued. Trade-off: a newer push no longer interrupts a running build for an older commit; it waits one build cycle. For nightly that's the right call. * feat(desktop): play white brand splash video on startup (#3674) (#3689) * feat(desktop): play white brand splash video on startup Replace the dark "Waiting for the web runtime URL…" placeholder with the brand intro animation on a white background. The clip plays once and then freezes on the settled "Open design" logo. The window only swaps to the web runtime after the clip has had time to play through (MIN_SPLASH_MS), so it never flips mid-animation, and it holds the final frame when the runtime takes longer to boot. The clip is embedded as a base64 data URL so it renders identically in dev and in packaged builds without any electron-builder asset wiring — the packaged app exposes no reliable on-disk asset path from the desktop main bundle (the previous icon lookup already silently fell back to null there). * feat(desktop): show splash in parallel with boot, reveal on web mount Make the white brand splash a dedicated window so it can do real work: - Packaged cold start now creates the splash BEFORE awaiting the daemon/web sidecars, so the animation is on screen during the whole boot instead of the user staring at no window at all for a few seconds. The 2s minimum splash now overlaps startup rather than being added on top of it. - The main window starts hidden and loads the web app behind the splash. We reveal it only once the web bundle reports it has actually mounted (data-od-app-mounted="1") and the minimum splash time has elapsed, then close the splash. This removes the dark "Loading Open Design…" web shell that used to flash between the splash and the app. A hard timeout guarantees the user is never stranded on the splash if the mount signal never arrives. tools-dev/dev keeps working unchanged: when no splash window is handed in, the runtime creates its own. * fix(desktop): measure splash hold from window creation, not adoption PerishCode review: in packaged startup the splash window is created before `await startPackagedSidecars(...)`, but the minimum-hold clock was stamped later inside `createDesktopRuntime` (after the sidecars had finished booting). The reveal then waited `MIN_SPLASH_MS` from that post-boot instant, so a fast web mount still added almost another 2s on top of boot — re-introducing the very delay the parallel splash was meant to remove. `createSplashWindow()` now returns a `{ window, startedAt }` handle stamped at creation; the packaged entry forwards `startedAt` via `splashStartedAt` and the runtime measures the hold from there. Runtime-created dev splashes stamp their own creation time, so the hold overlaps boot in every path. * fix(desktop): gate DesktopRuntime.show() behind the splash reveal PerishCode review: the main window now starts hidden and is revealed only after the web app mounts, but `DesktopRuntime.show()` still called `window.show()` unconditionally. The packaged entry routes a pending second-instance focus through that method via `onDesktopReady`, so launching the app twice while the sidecars/web are still booting would surface the half-loaded main window and bypass the reveal gate — reintroducing the startup flash. `show()` now focuses the splash while the runtime is pre-reveal and only shows the main window once `revealWhenReady()` has fired. * fix deck keyboard pagination double step * Fix official plugin and skill catalog search * fix(web): clear plugin context after send * fix(web): gate bundled plugin provenance in catalog Generated-By: looper 0.9.3 (runner=fixer, agent=codex) --------- Co-authored-by: yinjialu <2481044901@qq.com> Co-authored-by: Ray Xi <109605599+Siri-Ray@users.noreply.github.com> Co-authored-by: Amy <1184569493@qq.com> Co-authored-by: elihahah666 <template@refly.ai> Co-authored-by: qiongyu1999 <2694684348@qq.com> Co-authored-by: Amy <58060647+AmyShang-alt@users.noreply.github.com> Co-authored-by: chaoxiaoche <fanzhen910412@gmail.com> Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local> Co-authored-by: lefarcen <ontf116@gmail.com>
228 lines
8.4 KiB
TypeScript
228 lines
8.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
/**
|
|
* End-to-end coverage for chat file-link routing (issue #1239).
|
|
*
|
|
* Before this fix, every `<a>` rendered from chat markdown carried
|
|
* `target="_blank"` with no `onClick`. In Electron that hits the desktop
|
|
* `setWindowOpenHandler` and creates a new `od://` BrowserWindow; relative
|
|
* hrefs like `template.html` have no base so the new window can't resolve
|
|
* them and the user lands on the home screen. The fix detects in-project
|
|
* file paths in chat markdown and routes them through the existing
|
|
* `requestOpenFile` workspace tab opener.
|
|
*/
|
|
|
|
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { AssistantMessage } from '../../src/components/AssistantMessage';
|
|
import type { ChatMessage } from '../../src/types';
|
|
|
|
afterEach(() => cleanup());
|
|
|
|
function messageWithText(text: string): ChatMessage {
|
|
return {
|
|
id: 'assistant-1',
|
|
role: 'assistant',
|
|
content: text,
|
|
events: [{ kind: 'text', text }],
|
|
startedAt: 1_000,
|
|
endedAt: 3_000,
|
|
runStatus: 'succeeded',
|
|
};
|
|
}
|
|
|
|
describe('AssistantMessage — chat file-link routing (#1239)', () => {
|
|
it('routes a relative file-link click through onRequestOpenFile and suppresses the default new-window behavior', () => {
|
|
const onRequestOpenFile = vi.fn();
|
|
const { container } = render(
|
|
<AssistantMessage
|
|
message={messageWithText('Open [template.html](template.html) to preview.')}
|
|
streaming={false}
|
|
projectId="project-1"
|
|
onRequestOpenFile={onRequestOpenFile}
|
|
/>,
|
|
);
|
|
|
|
const anchor = container.querySelector('a.md-link');
|
|
expect(anchor).not.toBeNull();
|
|
expect(anchor?.getAttribute('href')).toBe('template.html');
|
|
|
|
// Dispatch a real DOM MouseEvent so defaultPrevented reflects what
|
|
// Electron's setWindowOpenHandler actually reads.
|
|
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
anchor!.dispatchEvent(clickEvent);
|
|
|
|
expect(onRequestOpenFile).toHaveBeenCalledTimes(1);
|
|
expect(onRequestOpenFile).toHaveBeenCalledWith('template.html');
|
|
expect(clickEvent.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it('normalizes ./ and nested subdirectory paths before opening', () => {
|
|
const onRequestOpenFile = vi.fn();
|
|
const { container } = render(
|
|
<AssistantMessage
|
|
message={messageWithText('Inspect [hero](./subdir/hero.html) section.')}
|
|
streaming={false}
|
|
projectId="project-1"
|
|
onRequestOpenFile={onRequestOpenFile}
|
|
/>,
|
|
);
|
|
|
|
const anchor = container.querySelector('a.md-link');
|
|
expect(anchor).not.toBeNull();
|
|
fireEvent.click(anchor!);
|
|
expect(onRequestOpenFile).toHaveBeenCalledTimes(1);
|
|
expect(onRequestOpenFile).toHaveBeenCalledWith('subdir/hero.html');
|
|
});
|
|
|
|
it('routes project raw file URLs through onRequestOpenFile instead of opening a new window', () => {
|
|
const onRequestOpenFile = vi.fn();
|
|
const { container } = render(
|
|
<AssistantMessage
|
|
message={messageWithText('Open [mutuals-v2.html](/api/projects/project-1/raw/mutuals-v2.html).')}
|
|
streaming={false}
|
|
projectId="project-1"
|
|
onRequestOpenFile={onRequestOpenFile}
|
|
/>,
|
|
);
|
|
|
|
const anchor = container.querySelector('a.md-link');
|
|
expect(anchor).not.toBeNull();
|
|
expect(anchor?.getAttribute('href')).toBe('/api/projects/project-1/raw/mutuals-v2.html');
|
|
|
|
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
anchor!.dispatchEvent(clickEvent);
|
|
|
|
expect(onRequestOpenFile).toHaveBeenCalledTimes(1);
|
|
expect(onRequestOpenFile).toHaveBeenCalledWith('mutuals-v2.html');
|
|
expect(clickEvent.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it('does not route app file URLs for a different project through the current workspace opener', () => {
|
|
const onRequestOpenFile = vi.fn();
|
|
const { container } = render(
|
|
<AssistantMessage
|
|
message={messageWithText('Open [index.html](/projects/other-project/files/index.html).')}
|
|
streaming={false}
|
|
projectId="project-1"
|
|
projectFileNames={new Set(['index.html'])}
|
|
onRequestOpenFile={onRequestOpenFile}
|
|
/>,
|
|
);
|
|
|
|
const anchor = container.querySelector('a.md-link');
|
|
expect(anchor).not.toBeNull();
|
|
|
|
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
anchor!.dispatchEvent(clickEvent);
|
|
|
|
expect(onRequestOpenFile).not.toHaveBeenCalled();
|
|
expect(clickEvent.defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it('routes same-origin absolute project raw URLs through onRequestOpenFile', () => {
|
|
const onRequestOpenFile = vi.fn();
|
|
const href = `${window.location.origin}/api/projects/project-1/raw/Web%20Prototype%20mutuals-v2.html`;
|
|
const { container } = render(
|
|
<AssistantMessage
|
|
message={messageWithText(`Open [Web Prototype mutuals-v2.html](${href}).`)}
|
|
streaming={false}
|
|
projectId="project-1"
|
|
onRequestOpenFile={onRequestOpenFile}
|
|
/>,
|
|
);
|
|
|
|
const anchor = container.querySelector('a.md-link');
|
|
expect(anchor).not.toBeNull();
|
|
|
|
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
anchor!.dispatchEvent(clickEvent);
|
|
|
|
expect(onRequestOpenFile).toHaveBeenCalledTimes(1);
|
|
expect(onRequestOpenFile).toHaveBeenCalledWith('Web Prototype mutuals-v2.html');
|
|
expect(clickEvent.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it('routes local absolute paths that match project files through onRequestOpenFile', () => {
|
|
const onRequestOpenFile = vi.fn();
|
|
const { container } = render(
|
|
<AssistantMessage
|
|
message={messageWithText(
|
|
'已完成单文件原型:[index.html](/Users/mac/open-design/open-design-preview-0.10.0/projects/Web%20Prototype/index.html)。',
|
|
)}
|
|
streaming={false}
|
|
projectId="project-1"
|
|
projectFileNames={new Set(['index.html'])}
|
|
onRequestOpenFile={onRequestOpenFile}
|
|
/>,
|
|
);
|
|
|
|
const anchor = container.querySelector('a.md-link');
|
|
expect(anchor).not.toBeNull();
|
|
|
|
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
anchor!.dispatchEvent(clickEvent);
|
|
|
|
expect(onRequestOpenFile).toHaveBeenCalledTimes(1);
|
|
expect(onRequestOpenFile).toHaveBeenCalledWith('index.html');
|
|
expect(clickEvent.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it('does not intercept external https:// URLs — preserves default target="_blank" behavior', () => {
|
|
const onRequestOpenFile = vi.fn();
|
|
const { container } = render(
|
|
<AssistantMessage
|
|
message={messageWithText('See [docs](https://example.com/docs) for context.')}
|
|
streaming={false}
|
|
projectId="project-1"
|
|
onRequestOpenFile={onRequestOpenFile}
|
|
/>,
|
|
);
|
|
|
|
const anchor = container.querySelector('a.md-link');
|
|
expect(anchor).not.toBeNull();
|
|
expect(anchor?.getAttribute('href')).toBe('https://example.com/docs');
|
|
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
anchor!.dispatchEvent(clickEvent);
|
|
expect(onRequestOpenFile).not.toHaveBeenCalled();
|
|
expect(clickEvent.defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it('does not intercept #anchor fragments', () => {
|
|
const onRequestOpenFile = vi.fn();
|
|
const { container } = render(
|
|
<AssistantMessage
|
|
message={messageWithText('Jump to [intro](#intro) of this page.')}
|
|
streaming={false}
|
|
projectId="project-1"
|
|
onRequestOpenFile={onRequestOpenFile}
|
|
/>,
|
|
);
|
|
|
|
const anchor = container.querySelector('a.md-link');
|
|
expect(anchor).not.toBeNull();
|
|
fireEvent.click(anchor!);
|
|
expect(onRequestOpenFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps default link behavior when the host did not pass onRequestOpenFile', () => {
|
|
// Some surfaces (e.g. read-only history view) intentionally do not
|
|
// pass `onRequestOpenFile`. The fix must not throw and the link must
|
|
// still render with its default target="_blank" behavior.
|
|
const { container } = render(
|
|
<AssistantMessage
|
|
message={messageWithText('Open [template.html](template.html) to preview.')}
|
|
streaming={false}
|
|
projectId="project-1"
|
|
/>,
|
|
);
|
|
|
|
const anchor = container.querySelector('a.md-link');
|
|
expect(anchor).not.toBeNull();
|
|
expect(anchor?.getAttribute('target')).toBe('_blank');
|
|
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
anchor!.dispatchEvent(clickEvent);
|
|
expect(clickEvent.defaultPrevented).toBe(false);
|
|
});
|
|
});
|