Skip to content

feat(node-ui): chat panel PR5 — My-projects picker + hover timestamps + inline streaming caret#528

Merged
Jurij89 merged 9 commits into
mainfrom
feat/chat-ui-pr5
May 15, 2026
Merged

feat(node-ui): chat panel PR5 — My-projects picker + hover timestamps + inline streaming caret#528
Jurij89 merged 9 commits into
mainfrom
feat/chat-ui-pr5

Conversation

@Jurij89
Copy link
Copy Markdown
Contributor

@Jurij89 Jurij89 commented May 15, 2026

Summary

Three deferred chat-panel follow-ups (post-#516). Per user direction, #3 assistant-turn separators and #1 Select typeahead were dropped.

  • A — Project picker = "My projects" membership. The composer's project <Select> listed every context graph; now it mirrors the left sidebar's membership predicate (belongsInMyProjectsSidebar). Extracted shared toSidebarIdentity + new pure computeSelectableProjects into contextGraphSidebar.ts. The local "hidden from sidebar" dismissal is intentionally NOT applied. An active-but-non-member project is still surfaced so the trigger never silently retargets. Other availableProjects consumers keep the full list.
  • B — Hover-only timestamps. Hidden by default, revealed on message hover / keyboard :focus-within, reusing the .v10-md-copy recipe. Touch + reduced-motion always show it. AA contrast unchanged. Pure CSS.
  • C — Inline streaming caret. Caret was a block sibling after the markdown (orphaned below code/tables/lists). Now injected inline after the last rendered text node via a small rehype tree transform (whitespace-only and fenced-code text skipped). Chosen over the planned sentinel char (leak-proof).

Tests

  • New computeSelectableProjects unit cases (membership, active-but-non-member inclusion, no-dup, null identity) in contextGraphSidebar.test.ts.
  • New streaming-caret cases in markdown-message.test.ts (one caret in last text node, none when not streaming, end-of-paragraph, not inside fenced code).
  • Full node-ui suite: 574 passed / 38 skipped / 0 failed (after building runtime workspace deps in the fresh worktree).

Test plan

  • Picker lists only created/joined projects; a public non-member CG (visible in Context Oracle) does not appear; active-but-non-member still selectable; "No project (clear selection)" works.
  • Timestamps hidden until message hover / keyboard focus; always visible on touch / reduced-motion; contrast unchanged dark + light.
  • During a live stream the caret sits inline after the last streamed character (incl. after lists/tables); vanishes on final; no stray glyph.

🤖 Generated with Claude Code

Jurij Skornik and others added 3 commits May 15, 2026 14:40
The composer's project <Select> listed every non-system context graph
while the left sidebar's "My projects" only shows ones the agent
created/joined. Reuse the sidebar's membership predicate so they agree.

- Extract `toSidebarIdentity` from PanelLeft into the shared
  contextGraphSidebar lib (one source of truth; decoupled structural
  param so the lib stays independent of the api layer).
- Add pure `computeSelectableProjects(available, identity, activeId)`:
  membership filter + a coherence rule that still surfaces an active
  project that isn't a member so the trigger never silently falls back
  to the placeholder. The local "hidden from sidebar" dismissal is
  intentionally NOT applied (hiding ≠ can't post chat there).
- PanelRight container derives the list (currentAgent already loaded)
  and passes it to the picker; prop is optional and defaults to the
  full list so unrelated ConnectedAgentsTab renderers/tests are
  unaffected. Other availableProjects consumers (display name, context
  entries) keep the full list.
- Note: this commit also carries the PanelRight render-path wiring for
  the streaming-caret change (same file; see follow-up commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-message timestamps were always visible, adding transcript noise now
that PR4 made assistant turns full-width with no bubble. Hide by default
and reveal on message hover / keyboard focus-within, reusing the exact
`.v10-md-copy` recipe (opacity + 120ms transition). Touch (hover:none)
and prefers-reduced-motion users always see it. Colour/contrast (AA per
Task #68) is unchanged — only visibility toggles. Pure CSS, no JSX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The caret rendered as a block sibling after the whole MarkdownMessage,
so it orphaned onto its own line below code/tables/lists. Inject it
inline right after the last rendered text node via a small rehype tree
transform, structure-independent.

Chosen over the originally-planned sentinel character: a tree transform
cannot leak a stray glyph into visible text if parsing splits
unexpectedly. Whitespace-only hast text nodes (the `\n` inserted between
block elements) and fenced-code text are skipped so the caret lands at
the true end of streamed prose. PanelRight passes `streaming` through
renderMessageContent (wiring committed alongside Item A — same file)
and keeps an inline caret for the plaintext path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/node-ui/src/ui/lib/contextGraphSidebar.ts
Comment thread packages/node-ui/src/ui/components/chat/MarkdownMessage.tsx Outdated
Jurij Skornik and others added 2 commits May 15, 2026 14:47
The streaming-caret skip matched `tag === 'code'`, which also matches
inline code (a bare <code> with no <pre> ancestor), so a message
ending in `` `inline` `` never got a caret and looked finished early.
Only propagate the code-skip through `pre` (fenced/preformatted, whose
text is rebuilt by CodeBlock). Adds a regression test for an
inline-code-ending streaming message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
qa-lead nit: the "no caret in fenced code" test used a `pre, code`
selector which, after the inline-code fix, would also match inline
<code> (which legitimately can hold the caret). Scope it to the
CodeBlock container `.v10-md-pre` so the assertion stays precise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/node-ui/src/ui/lib/contextGraphSidebar.ts
Comment thread packages/node-ui/src/ui/components/chat/MarkdownMessage.tsx Outdated
Comment thread packages/node-ui/src/ui/styles.css
…-token wait

Item C moved the streaming caret inside the last text node, so an
assistant turn that starts as `{ streaming: true, content: '' }` had
nothing to anchor the caret to and rendered a blank row until the first
token arrived (user-reported regression). Add a ChatGPT-style shimmer
"Thinking…" indicator for the empty-streaming state; it falls through to
the markdown path (whose inline caret takes over) the moment content
arrives. Pure CSS sheen with a solid `--text-secondary` fallback if
`background-clip:text` is unsupported, a static `prefers-reduced-motion`
state, and `role=status`/`aria-live` for AT. User picked the
shimmer-text option (over a brand-logo pulse) after UX/UI consultation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/node-ui/src/ui/components/Shell/PanelRight.tsx
Comment thread packages/node-ui/src/ui/components/chat/MarkdownMessage.tsx
Comment thread packages/node-ui/src/ui/styles.css
A streamed message whose current content has no HAST text node (e.g.
ends in an image `![alt](url)`) found no per-text-node anchor, so the
turn looked finished while still streaming. Add a trailing-caret
fallback for that case. It deliberately does NOT fire for a code-only
stream: `sawCodeText` distinguishes "text exists but is all code"
(caret intentionally suppressed — ChatGPT parity, ux-lead-approved)
from "no text node anywhere" (image-only — needs the caret). Adds two
regression tests: image-ending stream gets exactly one caret;
code-only stream still gets none.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/node-ui/src/ui/components/Shell/PanelRight.tsx
Comment thread packages/node-ui/src/ui/components/chat/MarkdownMessage.tsx Outdated
…rose

When a streaming message was prose followed by a trailing fenced code
block, the last non-code text target was the *earlier* prose, so the
caret was spliced mid-message before the code block (stale position —
looked like it was streaming in the wrong place). Track `trailingCode`:
set when a `<pre>` is entered, reset whenever a new non-code text
target is recorded. If a `<pre>` follows the last target, suppress the
caret (same outcome as a code-only stream — ChatGPT parity,
ux-lead-approved) instead of parking it on stale prose. Code-earlier-
then-prose still correctly anchors to the trailing prose. Two
regression tests added (trailing-code suppression; inverse ordering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/node-ui/src/ui/components/chat/MarkdownMessage.tsx Outdated
The stale-anchor guard only covered a trailing <pre>; the same bug
applied to any non-text tail (trailing image, hr, hard <br>) — the
caret stayed on earlier prose, spliced before the final block.

Replace the `<pre>`-only boolean with a document-order `tail` model:
'text' → splice the inline caret at the last text node; 'code' →
suppress (CodeBlock rebuilds the subtree; ChatGPT parity,
ux-lead-approved); 'leaf' (img/hr/br) → append a trailing caret so the
turn still reads as streaming; 'none' → nothing (empty content is the
upstream "Thinking…" indicator's job). Subsumes the prior code-only /
image-only / trailing-code cases. Adds regression tests: image-after-
prose, hr-after-prose, mid-paragraph <br> stays inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/node-ui/src/ui/components/Shell/PanelRight.tsx
Comment thread packages/node-ui/src/ui/styles.css
@Jurij89 Jurij89 merged commit 531ce03 into main May 15, 2026
33 checks passed
@branarakic branarakic mentioned this pull request May 15, 2026
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant