Commit Graph
14 Commits
Author SHA1 Message Date
fa19a9fc6f fix(daemon): pass Grok Build prompts via files (#3898)
* fix(daemon): pass Grok Build prompts via files

* fix(web): recover Grok HTML emitted before artifact tag

* fix(daemon): filter Grok models and effort args

* fix(web): persist standalone HTML agent responses

* fix(web): persist fenced HTML agent responses

* fix(web): hide Grok fallback HTML in chat

* fix(web): hide recovered pre-artifact HTML

Generated-By: looper 0.9.7 (runner=fixer, agent=opencode)

---------

Co-authored-by: audit <a@b.c>
2026-06-09 02:15:17 +00:00
lefarcenandGitHub 5555b2eca9 fix(web): keep the streaming question-form id stable (#3582 follow-up) (#3644)
* 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.

* 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.

* fix(web): decouple the question-form React key from the parsed form id

Root-cause fix for the identity churn three reviewers flagged. The Questions
panel was keyed on `${conversation}:${message}:${form.id}`, so any change to the
parsed id remounted it and dropped in-progress answers. The streaming preview
shows the `discovery` fallback id until the body id arrives, and a form that
emits answerable questions before its id settles flips identity while the user
is mid-answer — exactly the remount they described.

There is at most one (first) form per assistant message, so the id is redundant
for uniqueness: `${conversation}:${message}` already identifies the occurrence,
and a distinct later form lives in its own message (its own key, still replays
the reveal). Extract buildQuestionFormKey (id-free) and unit-test that the key
is stable across a streaming id flip, distinct across messages, and null until
conversation/message/form are all present.

completeTopLevelString stays — it settles the *displayed* id early — but the id
no longer drives identity, so even the questions-before-id ordering can't
remount the panel.
2026-06-04 11:22:36 +00:00
lefarcenandGitHub 6e10fa4852 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.
2026-06-04 09:11:28 +00:00
f3ec4968af feat(web): revamp chat UI styling + live streaming code card (#3382)
* feat(web): revamp chat UI styling + live streaming code card

Restyle the chat pane (chat/tokens/code/tools/routines/composio CSS) and
add a live code-component card that streams Write/Edit tool input and
<artifact type="text/html"> bodies character-by-character via a new
ephemeral tool_input_delta SSE event, flipping to the completed
FileWriteCard/FileEditCard view on close. Includes shiki highlighting,
markdown/artifact strip handling, tool-status cleanup, and i18n keys.

* chore(nix): refresh pnpm deps hash for merged lock

---------

Co-authored-by: qiongyu1999 <2694684348@qq.com>
2026-06-03 16:39:20 +00:00
吴杨帆andGitHub 9641c9e11c fix(web): parse ask-question blocks as question-form alias (#1194) (#3053)
Accept <ask-question> as an alias for <question-form> and locate close
tags with a Unicode-safe scan so Turkish dotted-I prose before the tag
does not desync parser indices.
2026-05-27 06:34:36 +00:00
fce444bcab Consolidate chat comments preview on main (#2906)
* feat(web): queue chat sends

* feat(web): render code comment directives

* feat(web): add preview comments and manual edits

* fix(web): polish shared chrome controls

* fix(web): align queued send loading state

* feat(web): open primary project artifacts

* fix(web): keep queued sends and tests aligned

* fix(web): restore docked comment tools layout

* fix(web): align preview comment toolbar

* fix(web): place local cli beside handoff

* fix(web): move agent menu beside handoff

* fix(web): make project instructions a direct header action

* fix(web): compact handoff and toolbar labels

* fix(web): clarify handoff menu and annotation label

* fix(web): restore compact cursor handoff trigger

* fix(web): align agent menu trigger with handoff

* fix(web): add draw toolbar close action

* fix(web): move inspect editing into edit mode

* fix(web): avoid reserving comment sidebar in annotation mode

* fix(web): float preview comments panel

* fix(web): keep edit canvas full width

* fix(web): polish preview annotation tools

* fix(web): highlight active preview comments

* fix(web): open comments panel after annotation save

* fix(web): polish comment handoff controls

* fix(web): remove palette preview tool

* fix(web): simplify draw annotation toolbar

* fix(web): restore queued tasks into composer

* fix(web): restore queued send strip styling

* fix(web): hide internal comment target ids

* fix(web): align manual edit panel header

* test(web): cover visual interaction contracts

* fix(web): address PR feedback regressions

* fix(web): preserve artifact chrome state

* fix(daemon): restore project raw file routes

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-26 10:31:19 +00:00
22e057cc6a Reject reserved project paths in HTML artifacts (#2484)
* Reject reserved paths in HTML artifacts

* Handle reserved artifact path edge cases

---------

Co-authored-by: Ghxst <200635707+GHX5T-SOL@users.noreply.github.com>
2026-05-22 19:09:33 +08:00
nmsnandGitHub 575d5aaf2f fix(web): strip trailing punctuation from markdown link URLs (#2568) (#2591) 2026-05-22 14:04:39 +08:00
kamiandGitHub ac748d53fc Fix pointer HTML artifact previews (#2075)
Resolves short pointer-only HTML artifacts to the existing project HTML target before persisting preview artifacts, with helper and ProjectView regression coverage for #536. Validation: CI and nix-check were green on PR head 8c7ee5f38d.
2026-05-18 18:28:28 +08:00
DongsenandGitHub 8e6250f2c1 feat(web): render GFM tables in markdown artifact and chat renderers (#1496)
* feat(web): render GFM pipe tables in markdown artifact and chat renderers

Both hand-rolled markdown parsers (artifact preview and assistant chat)
ignored pipe-table syntax, so GFM tables collapsed into a single
paragraph. Add a table block to each parser that:

- detects header+alignment+body rows;
- honors :--- / :---: / ---: column alignment;
- preserves inline formatting (code, bold, italic, links) inside cells;
- restores escaped \| as a literal pipe;
- breaks a preceding paragraph at a table start without a blank line.

Closes #1495

* fix(web): use scan-based table cell splitter, drop NUL placeholder

Address review on #1496:

- lefarcen (P1): the placeholder approach in both splitters was unsafe.
  artifacts/markdown.ts embedded literal NUL bytes around 'ODPIPE', which
  flipped the whole file to binary in GitHub diffs and hid the patch from
  review. runtime/markdown.tsx used a printable ' ODPIPE ' sentinel that
  could collide with real cell content.
- codex (P2): both splitters ran before inline parsing, so a literal '|'
  inside a backtick code span (e.g. a TypeScript union cell like
  \`\"ready\" | \"done\"\`) was treated as a column boundary and shredded
  the row.

Both issues collapse to one root cause — splitting by regex on '|'
without knowing what's a code span and what's an escape. Replace the
placeholder-then-split routine in both files with a single character-
level state-machine scanner that:

- treats '|' inside backticks as cell content (tracks inCode);
- resolves '\\|' to a literal '|' as it accumulates each cell;
- strips a single optional leading '|' and a single unescaped trailing
  '|' as row terminators rather than empty cells.

No placeholders, no NUL bytes, no collision surface. Add a test in each
test file covering the GFM-style pipe-inside-code-span cell, and the
runtime test additionally asserts the row produces exactly two <td>
cells (i.e. no phantom column from the embedded pipe).

* style(web): make markdown table apply in artifact viewer + add header bg, borders

The prior `.md-table` rules were scoped to `.prose-block`, which only
wraps the chat assistant path (AssistantMessage). The artifact preview
in FileViewer renders into `.markdown-rendered`, so tables in long-form
documents (the most common case) inherited no styling and showed as
borderless, structureless rows.

Lift the rule out of `.prose-block` so both paths share it. Visual
treatment matches the existing prose-block ecosystem (blockquote,
markdown-status):

- `var(--border)` for cell + outer borders, 6px outer radius.
- `var(--bg-panel)` on `<th>` for the header band — same token already
  used by blockquote and code-copy chip.
- Even-row zebra via `color-mix(in oklab, var(--bg-panel) 40%, transparent)`
  for a very faint stripe that holds up in both light and dark themes
  (no hardcoded color).
- Padding bumped from 4×8 to 8×12; cells were touching before.
- `display: block; overflow-x: auto` kept so wide tables scroll inside
  narrow columns instead of pushing layout.

* fix(web): wrap md-table in scroll container so columns fill width

Previously `.md-table` itself was `display: block; overflow-x: auto`
to let wide tables scroll horizontally. The side effect: a block-mode
table no longer behaves like `<table>` for layout, so columns collapse
to natural content width and leave empty space on the right when the
content is narrower than the container.

Split the two concerns: the new `.md-table-wrap` <div> owns the scroll
viewport (border, radius, overflow-x), and the inner `<table>` keeps
its native `display: table` with `width: 100%` so column widths
distribute across the available space again. Wide tables still scroll;
narrow tables now fill the container.

Both render paths (chat runtime + artifact HTML) emit the wrapper, and
the corresponding unit tests assert the nested shape.
2026-05-13 21:15:23 +08:00
Yiang YiyanandGitHub 5ff578dc8d fix: support object-style question-form options (#1293)
* fix: support object-style question-form options

* fix: preserve stable option values in form submissions
2026-05-12 17:03:45 +08:00
DongsenandGitHub 12ce5ad38b fix(web): ignore <artifact> tags inside markdown code spans and fences (#1132)
* test(web): add failing parser cases for <artifact> recitation in markdown code

Cover the three real-world prose contexts where the model legitimately
quotes the artifact tag without intending to emit one:

- inside an inline backtick span
- inside a fenced code block
- spread across streaming chunks crossing the fence boundary

Establishes the RED baseline before parser code-fence awareness lands.

* fix(web): ignore <artifact> tags inside markdown code spans and fences

The streaming artifact parser scanned the buffer with a raw indexOf,
guarded only by 'next char must be whitespace'. That meant any literal
<artifact ...> the model recited while documenting the protocol — even
inside backticks or a ```html fence — flipped the parser into artifact
mode, swallowed the rest of the reply from the chat UI, and (when a
matching </artifact> appeared in the recitation) silently wrote a
spurious file to disk via persistArtifact.

Replace findOpenTag with a linear scan that tracks fenced code blocks
(```) and inline code spans (`), skipping any <artifact prefix found
inside either. If the buffer ends mid-fence, return a partial match
anchored at the fence start so the next streaming chunk can resolve
the boundary without losing fence context.

Closes #1130.

* fix(web): match renderer fence/inline-code rules in artifact parser

Codex review on PR #1132 caught that the previous fix toggled inFence on
any triple-backtick run anywhere in the buffer, including mid-line, while
the chat renderer (apps/web/src/runtime/markdown.tsx) only treats ``` as
a fence when it occupies a whole line matching /^[ ]{0,3}```(\w[\w+-]*)?\s*$/.
That asymmetry would suppress a real <artifact> tag emitted after a prose
sentence like "the opening marker is ```html and the response then writes:".

Rework findOpenTag in three passes that mirror the renderer:

  1. Walk \n-terminated lines; only a line that matches FENCE_LINE_RE
     toggles fence state. Open fences without a close (or with an
     unterminated tail line) return partial so the next chunk can resolve.
  2. Collect inline code spans with /`[^`]+`/g — the same regex used by
     renderInline — so what the parser skips matches what the user sees as
     code. Unmatched trailing backticks after the last \n hold back.
  3. Find the first <artifact …> outside any skip range; preserve the
     existing partial-prefix tail handling.

Adds a regression test covering the exact case Codex reported.

* test(web): pin parser behavior on double-backtick and in-fence string literal recitation

Two cases raised in PR #1132 review:

- a real artifact tag wrapped in '``<artifact …>``' (double-backtick
  inline code span) should not be treated as a real artifact
- a fenced JS example whose body contains a string literal like
  'const fence = "```";' should not pop fence state early and let a
  later literal <artifact> be parsed as real

Both already pass on 96e88ca because the line-anchored fence regex and
the renderer-aligned inline regex handle them correctly. Pinning the
behavior so future regressions surface as test failures.

* fix(web): make stripArtifact markdown-aware to stop truncating literal recitations

The streaming artifact parser was hardened in 96e88ca to skip <artifact>
recitations inside backticks and fences, but the post-stream stripper at
AssistantMessage.tsx still ran a naive 'content.indexOf("<artifact")' over
the same text events. As reported by lefarcen on PR #1132, that meant
chat replies with literal protocol recitations could still get silently
truncated mid-explanation — even though the parser preserved them in the
text stream and the file panel was no longer polluted with ghost files.

Extract the renderer-aligned classification (FENCE_LINE_RE, INLINE_CODE_RE,
computeSkipRanges, rangeContains) into a single source of truth at
apps/web/src/artifacts/markdown-context.ts so the parser and the stripper
agree on what counts as code. Add apps/web/src/artifacts/strip.ts with a
markdown-aware stripArtifact that:

- ignores any <artifact open inside a fenced block or inline code span
- looks for </artifact> with the same skip-range filter, so a real open
  paired with a literal close inside backticks does not strip a literal
  body that is meant to render
- returns content unchanged when an open exists with no matching real
  close (the previous implementation sliced to end-of-string, which would
  nuke trailing prose on a malformed or still-streaming tag)

Refactor parser.ts to import the shared helpers; behavior preserved (all
seven existing parser tests still pass). New strip.test.ts covers six
cases including the empirically-verified inline-backtick regression.

* fix(web): align artifact stripper/parser fence rules with renderer exactly

Two gaps surfaced in review at a0bf05f:

- markdown-context.ts used a single FENCE_LINE_RE that allowed 0-3 leading
  spaces and reused the same pattern for opening and closing fences. The
  chat renderer (runtime/markdown.tsx:44 and :49) is asymmetric — opens
  with /^```(\w[\w+-]*)?\s*$/, closes with /^```\s*$/, and rejects any
  leading indentation on either side. Indented "   ```html" was being
  treated as a code fence even though the renderer keeps it as a paragraph,
  and a literal "```html" line inside an open fenced example was closing
  the skip range early — both could expose a real or literal <artifact …>
  to the wrong handler.
- stripArtifact discarded computeSkipRanges' unclosedFenceStart, so a
  fenced literal that ends at EOF without a trailing newline (very common
  for chat output) leaked the inner <artifact …> recitation to the
  stripper, reproducing the original #1130 truncation symptom on a
  narrower input shape.

Split FENCE_LINE_RE into FENCE_OPEN_RE / FENCE_CLOSE_RE with no leading
indentation, gate the fence state machine on the right side of the toggle,
and have stripArtifact extend skip ranges to end-of-content when a fence
is left open. Also tightened the parser's tail-line hold-back regex to
match the renderer's no-leading-space rule. Added regression tests for the
EOF-unclosed-fence case, the indented pseudo-fence (renderer treats as
paragraph, stripper must strip the real artifact), and a "```html" line
inside an open fence.

Refs nexu-io/open-design#1130

* refactor(web): align streaming tail-line fence guard with FENCE_OPEN_RE

The streaming parser's tail-line hold-back used a stricter local regex
(/^```\w*$/) than the renderer's FENCE_OPEN_RE (/^```(\w[\w+-]*)?\s*$/),
missing valid opener tails like ```c++, ```ts-, or ``` (trailing space).

In practice these tails are still held back by the unmatched-backtick
parity scan that runs immediately after — three backticks in a tail line
are odd, so firstUnmatched stays set and the parser holds from that
position. So this wasn't a runtime correctness bug, just a regex
divergence that future readers could trip on.

Drop the local regex and reuse FENCE_OPEN_RE so the tail check matches
the same shape the rest of the pipeline already uses. Pinned the
behavior with three new parser tests (`+`/`-` info-string suffix and
trailing-space tails arriving as the first chunk) — they pass at HEAD,
proving the parity scan was already covering these cases.

Refs nexu-io/open-design#1132 (lefarcen polish P2)

* fix(web): scope inline-code skip ranges per block and reject <artifact prefix-shared opens

INLINE_CODE_RE previously ran over the whole buffer, so an unmatched
backtick in one paragraph could pair with a backtick in a later
paragraph and create a phantom inline span that swallowed any real
<artifact …> between them. Mirror runtime/markdown.tsx by splitting the
buffer on fence / blank / heading / list / hr boundaries and running
INLINE_CODE_RE per block region instead.

stripArtifact accepted any unskipped `<artifact` substring as a real
open, while the streaming parser already required a following whitespace
character — so prose like `<artifactual>demo</artifact>` was being
truncated to `prefix  suffix`. Extract the parser's real-open guard into
isRealArtifactOpenAt and reuse it from both sides.

While reordering findOpenTag for the shared guard, also fix the related
hold-back ordering issue tracked at #1141: a stray tail-line backtick or
fence-opener prefix used to suppress an artifact already complete
earlier in the buffer. Scan for the earliest complete real open first,
then pick the earliest hold-back position only when no complete tag was
found.

Regressions pinned in parser.test.ts and strip.test.ts for both new
finding shapes.

* fix(web): keep HR-shaped lines inside paragraph regions for inline-code scanning

The previous walker closed inline-scan regions on lines matching the HR
regex, but `parseBlocks()` in runtime/markdown.tsx does not break a
paragraph on HR — its inner accumulation loop only breaks on blank /
fence / heading / ul / ol (runtime/markdown.tsx:95-104). HR is only an
HR block in the outer loop's first-look, never mid-paragraph.

So inputs like `intro \`\n---\n<artifact …>…</artifact>\n---\nclosing \``
are one paragraph in the renderer, whose two stray backticks pair to
cover the literal artifact recitation — but the walker was splitting on
the `---` lines, leaving the recitation outside skip ranges, and the
parser/stripper would treat it as a real tag.

Drop HR from the paragraph-break list (HR-shaped lines carry no
backticks of their own, so keeping them inside the surrounding region
is benign either way) and document the renderer-mirror rationale.

Regressions pinned on both sides.
2026-05-11 19:29:22 +08:00
DongsenandGitHub 7c1db80893 fix(web): 写盘前拦截 prose-as-HTML artifact (#50) (#1144)
* fix(web): reject prose-as-HTML artifacts at write time (#50)

AI 偶尔会在仅做 in-place 编辑(无新 canonical HTML 产出)时仍按
system prompt 的非协商性收尾规则发出 `<artifact type="text/html">`
块,但块内只装一句中文总结。`persistArtifact` 之前不做内容校验,
此类 prose 会作为合法 HTML 落盘到 `.od/projects/<id>/<id>.html`,
并附带 `kind: html` manifest,污染项目文件面板(截图见 #50 评论)。

新增 `validateHtmlArtifact` 纯函数:要求非空 + 长度 ≥64 + 含
`<!doctype html>` 或 `<html>` 标签(大小写不敏感、容忍 BOM)。
`persistArtifact` 在 `ext === '.html'` 分支调用 gate,失败时
通过 `setError` 报错且不写文件。

scope 限于 `<artifact>`-tag 持久化路径——FileViewer/FileWorkspace
里用户手动保存草稿 HTML 走的是不同入口,不受影响。

prompt 层根因(缺少免发条款)已拆出 #1143 单独跟进,本 PR 是
持久化层的兜底防御。

* fix(web): anchor HTML structural check at content start (#1144 review)

mrcfps 在 #1144 review 指出原实现的 false negative:
HTML_OPENING_TAG_RE / DOCTYPE_RE 用 .test() 在整个字符串里搜,
所以 AI 描述改动时 inline 引一个 tag 名("Updated the <html lang>
attribute...")就能蒙混过关——长度过 64、含 `<html `——同样
落地为幽灵 HTML 文件。

修复:合并两个 regex 成 STARTS_WITH_DOCUMENT_RE,加 ^ anchor,
要求 trimmed 内容的首个非空白 token 必须是 `<!doctype html>`
或 `<html`。Mid-string 出现的 tag 名不再算数。

同时按 lefarcen 的非阻塞文档建议把 docblock 改写得更精确:
- "structural sniff" 替代 "validation",明确不是 HTML 校验器
- 列出 not-a-linter / .jsx-tsx-skipped / 用户手动保存路径不受
  影响 三条 scope 边界
- 64 字符阈值会拒收 49 字符的最小空 doc(如
  `<!doctype html><html><body></body></html>`),明确这是有意
  trade-off:AI 产出预期是 non-trivial deliverable

新增 3 例测试覆盖 mrcfps 描述的 false negative:
- 长 prose 中 inline 引 `<html lang>` 应拒收
- 长 prose 中 inline 引 `<!doctype html>` 应拒收
- 首个 token 是 `<p>` 等非文档标签的 fragment 应拒收
2026-05-10 20:22:48 +08:00
PerishFireandGitHub bbdd4e84b5 chore: enforce test directory conventions (#496)
* chore: enforce test directory conventions

Move package, app, and tool tests out of src and add guard enforcement so source directories stay source-only.

* ci: use guard and package-scoped tests

Run the new repository guard in CI and keep test execution aligned with package-scoped commands after removing root aliases.

* ci: align stable release guard check

Use the new repository guard in stable release verification after replacing the residual-JS-only script.

* chore: tighten test layout enforcement

Enforce sibling tests directories, typecheck moved test suites with dedicated configs, and refresh remaining guidance that pointed at src-based tests.

* chore: clarify no-emit test tsconfigs

Explicitly disable declaration-only emit in test tsconfigs so review tooling sees they are no-emit typecheck configs.
2026-05-05 15:34:22 +08:00