Skip to content

Commit cb77d6c

Browse files
OpenSource03claude
andcommitted
fix: chat virtualization stability and margin collapse fixes
- Key ChatView by sessionId to force clean virtualizer remounts on session switch - Enable useAnimationFrameWithResizeObserver for stable row measurement during animations - Add flow-root to virtual rows and message containers to prevent margin collapsing - Cancel pending rAF flush on session switch to prevent stale streaming data overwrites - Debounce space switch effect by 60ms to prevent race conditions between concurrent session operations - Update release notes template with user-facing writing guidance Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1fa614d commit cb77d6c

9 files changed

Lines changed: 119 additions & 75 deletions

File tree

.claude/skills/release/references/release-notes-template.md

Lines changed: 61 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,45 @@
77
- Use an em dash (``), not a hyphen
88
- Name 2-3 headline features, joined by commas and `&`
99
- Examples:
10+
- `v0.21.0 — Virtualized Chat, Mermaid Diagrams & Deep Folder Tagging`
1011
- `v0.15.0 — Slash Commands, Tool Grouping & Project Files`
1112
- `v0.14.0 — Codex Engine Config, Auth Flow & Settings Refresh`
1213
- `v0.13.1 — Windows Compatibility Fixes`
1314

15+
## Audience & Tone
16+
17+
**Write for users, not developers.** Release notes are read by people who use the app, not people who built it.
18+
19+
- ✅ "Long conversations are dramatically faster now"
20+
- ❌ "Replaced `content-visibility: auto` with `@tanstack/react-virtual` windowing"
21+
- ✅ "When Claude draws a diagram, it now actually renders as a visual diagram"
22+
- ❌ "Mermaid fenced code blocks render as SVG via async `mermaid.render()` with LRU cache"
23+
- ✅ "Type `/clear` in the composer and hit Enter to open a fresh chat"
24+
- ❌ "Added `LOCAL_CLEAR_COMMAND` slash command with `source: 'local'` that calls `onClear()` callback"
25+
26+
**Rules of thumb:**
27+
- Describe what the user *experiences*, not what the code does
28+
- No internal names, no version numbers, no API terms, no implementation details
29+
- If you can't explain it in plain English, simplify or skip it
30+
- Bug fixes: describe the symptom the user saw, not the root cause
31+
1432
## Notes Structure
1533

34+
Sections can be free-form paragraphs OR bullet lists — pick whichever reads more naturally.
35+
1636
```markdown
1737
## What's New
1838

19-
### {emoji} {Category Title}
20-
- **{Feature name}** — description of what it does
21-
- **{Feature name}** — description
39+
### {emoji} {User-Facing Section Title}
40+
Short paragraph explaining what changed and why users care.
41+
42+
### {emoji} {User-Facing Section Title}
43+
- **{Feature name}** — what it does for the user
44+
- **{Feature name}** — what it does for the user
2245

23-
### {emoji} {Category Title}
24-
- **{Feature name}** — description
46+
### 🐛 Bug Fixes
47+
- Fixed a bug where [symptom the user experienced]
48+
- Fixed [thing] that caused [user-visible problem]
2549

2650
---
2751

@@ -31,64 +55,65 @@
3155
## Rules
3256

3357
1. Use `## What's New` for feature releases, `## Changes` for patch/fix-only releases
34-
2. Group under `### {emoji} {Category Title}` headers
35-
3. Bullets: `**bold feature name** — description` (em dash)
36-
4. End with `---` separator and Full Changelog link
58+
2. Group under `### {emoji} {Category Title}` headers — keep titles plain, not technical
59+
3. End with `---` separator and Full Changelog link
60+
4. **Always write for users first.** Internal details, library names, and implementation notes belong in commit messages and CLAUDE.md, not release notes.
3761

3862
## Emoji Conventions
3963

4064
| Emoji | Category |
4165
|-------|----------|
42-
|| Performance, speed, commands, autocomplete |
43-
| 📦 | Grouping, packaging, bundling |
44-
| 📂 | Files, filesystem, project structure |
45-
| 🔍 | Search, inspection, debugging |
66+
|| Performance, speed, snappiness |
67+
| 📦 | Grouping, packaging |
68+
| 📂 | Files, folders, filesystem |
69+
| 🔍 | Search, inspection |
4670
| 📨 | Messages, queues, communication |
47-
| 🛠 | Tools, subagents, integrations |
48-
| 🎨 | UI, polish, visual changes, icons |
49-
| ⚙️ | Configuration, settings, engines |
50-
| 🔐 | Auth, security |
51-
| 🔄 | Updates, syncing, auto-refresh |
52-
| 🌳 | Git, worktrees, version control |
71+
| 🛠 | Tools, integrations |
72+
| 🎨 | UI, visual polish |
73+
| ⚙️ | Settings, configuration |
74+
| 🔐 | Auth, security, permissions |
75+
| 🔄 | Updates, syncing |
76+
| 🌳 | Git, version control |
5377
| 🐛 | Bug fixes |
5478
|| New features (generic) |
5579

56-
## Example: Feature Release (v0.15.0)
80+
## Example: Feature Release (v0.21.0)
5781

5882
```markdown
5983
## What's New
6084

61-
### ⚡ Slash Command Autocomplete
62-
- **Unified `/` command picker** in InputBar — type `/` to browse commands from Claude, ACP, and Codex engines
63-
- **Keyboard navigation** with arrow keys, Enter/Tab to select, Escape to dismiss
85+
### ⚡ Much Faster Chat
86+
Long conversations are dramatically faster now. We replaced the old rendering approach with a proper virtualized list — only the messages you can actually see are rendered at any time. Scrolling is smoother, switching sessions is snappier, and the app uses less memory overall.
87+
88+
### 📊 Mermaid Diagrams
89+
When Claude draws a diagram using a mermaid code block, it now actually renders as a visual diagram — flowcharts, sequence diagrams, pie charts, git graphs, and more. Diagrams adapt to your light/dark theme automatically. While Claude is still typing, you see the raw source; once the message is complete, the diagram appears.
6490

65-
### 📦 Tool Group Collapsing
66-
- **Automatic grouping** — contiguous tool_call sequences merge into a collapsible summary block
67-
- **Animated morph transition** — tools compress into a grouped header with staggered row animations
91+
### 📂 Deep Folder Inclusion (`@#`)
92+
You can now use `@#foldername` in the composer to include the full contents of a folder — not just the file tree, but every file inside it. Regular `@folder` still gives you the structure overview. If the folder is large, Harnss will warn you before sending.
6893

69-
### 📂 Project Files Panel
70-
- **Full filesystem tree browser** — walks entire project directory (skips .git, node_modules)
71-
- **Search filtering** with debounced input and auto-expand matching directories
94+
### ⌨️ `/clear` Command
95+
Type `/clear` in the composer and hit Enter to instantly open a fresh chat — without sending anything to the agent.
7296

73-
### 🎨 UI & Polish
74-
- **App icon refresh** — display-p3 gradient, dark/light opacity specializations
75-
- **SDK bump** to `@anthropic-ai/claude-agent-sdk` 0.2.68
97+
### 🐛 Bug Fixes
98+
- Fixed a bug where switching out of plan mode could reset the permission level incorrectly
99+
- Fixed markdown characters occasionally getting eaten during streaming (apostrophes, backticks, etc.)
100+
- Permission prompts now show a notification if something goes wrong, instead of failing silently
76101

77102
---
78103

79-
**Full Changelog**: https://github.com/OpenSource03/harnss/compare/v0.14.3...v0.15.0
104+
**Full Changelog**: https://github.com/OpenSource03/harnss/compare/v0.20.0...v0.21.0
80105
```
81106

82107
## Example: Patch Release
83108

84109
```markdown
85110
## Changes
86111

87-
### 🐛 Windows Compatibility
88-
- **Windows ARM64 binary detection** — fixed Codex binary path resolution for ARM64 Windows
89-
- **npm pack EINVAL workaround** — handle Windows-specific EINVAL error during npm pack
112+
### 🐛 Bug Fixes
113+
- Fixed the app hanging when switching sessions during an active stream
114+
- Fixed copy button not working in certain sandboxed contexts
90115

91116
---
92117

93-
**Full Changelog**: https://github.com/OpenSource03/harnss/compare/v0.13.0...v0.13.1
118+
**Full Changelog**: https://github.com/OpenSource03/harnss/compare/v0.21.0...v0.21.1
94119
```

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,9 @@ Always search the web when needed for up-to-date API references, Electron APIs,
253253
**Release notes format**:
254254
- Start with `## What's New` (for feature releases) or `## Changes` (for smaller releases)
255255
- Group changes under `### Emoji Section Title` headers (e.g., `### 🌳 Git Worktree Management`)
256-
- Each bullet: **bold the feature name**, then describe what it does
257256
- End with `---` separator and `**Full Changelog**: https://github.com/OpenSource03/harnss/compare/v{prev}...v{current}`
258257
- Use `gh release create` with tag, then `gh release edit` to set title + notes
258+
- **Write for users, not developers** — describe what the user *experiences*, never mention internal names, library names, or implementation details. "Long conversations are dramatically faster" not "replaced content-visibility with @tanstack/react-virtual". Full guidance in `.claude/skills/release/references/release-notes-template.md`.
259259

260260
**Commit message format** (conventional commits):
261261
- `feat: short description` — new features

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "harnss",
3-
"version": "0.21.0",
3+
"version": "0.21.1",
44
"productName": "Harnss",
55
"description": "Harness your AI coding agents — one desktop app for Claude Code, Codex, and any ACP agent",
66
"author": {

src/components/ChatView.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,10 @@ export const ChatView = memo(function ChatView(props: ChatViewProps) {
302302
);
303303
}
304304

305-
return <ChatViewContent {...props} />;
305+
// Key by sessionId to force a clean remount on session/space switch.
306+
// This guarantees a fresh Virtualizer instance with no stale itemSizeCache,
307+
// measurementsCache, or ResizeObserver subscriptions from the previous session.
308+
return <ChatViewContent key={props.sessionId ?? "__empty__"} {...props} />;
306309
});
307310

308311
// ── ChatViewContent (inner, module-level, virtualized rendering) ──
@@ -494,6 +497,10 @@ function ChatViewContent({
494497
estimateSize: (index) => estimateRowHeight(rows[index]),
495498
getItemKey: (index) => getRowKey(rows[index]),
496499
overscan: 5,
500+
// Row heights change via markdown margins, collapsibles, and tool-group
501+
// morph animations. Measure inside rAF so ResizeObserver updates align
502+
// with paint and don't race mid-transition.
503+
useAnimationFrameWithResizeObserver: true,
497504
});
498505

499506
// ── Scroll handling (rerender-defer-reads, rerender-use-ref-transient-values) ──
@@ -658,6 +665,7 @@ function ChatViewContent({
658665
key={getRowKey(rows[virtualRow.index])}
659666
ref={virtualizer.measureElement}
660667
data-index={virtualRow.index}
668+
className="flow-root"
661669
style={{
662670
position: "absolute",
663671
top: 0,

src/components/MessageBubble.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ export const MessageBubble = memo(function MessageBubble({
334334
<div className={`flex justify-start px-4 ${isContinuation ? "py-0.5" : "py-1.5"}`}>
335335
<Tooltip>
336336
<TooltipTrigger asChild>
337-
<div className="min-w-0 wrap-break-word max-w-[85%]">
337+
<div className="flow-root min-w-0 max-w-[85%] wrap-break-word">
338338
{showThinking && message.thinking && (
339339
<div className={message.content ? "mb-2" : undefined}>
340340
<ThinkingBlock
@@ -347,7 +347,7 @@ export const MessageBubble = memo(function MessageBubble({
347347
{message.content ? (
348348
<div
349349
ref={proseRef}
350-
className="prose dark:prose-invert prose-sm max-w-none text-foreground [&_li::marker]:text-foreground dark:[&_li::marker]:text-foreground/70"
350+
className="flow-root prose dark:prose-invert prose-sm max-w-none text-foreground [&_li::marker]:text-foreground dark:[&_li::marker]:text-foreground/70"
351351
>
352352
<IsStreamingMarkdownContext.Provider value={!!message.isStreaming}>
353353
<ReactMarkdown

src/components/SummaryBlock.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const SummaryBlock = memo(function SummaryBlock({ message }: SummaryBlock
4747
const fallbackLabel = isCompact ? "Context compacted" : "Context resumed from previous conversation";
4848

4949
return (
50-
<div className="mx-4 my-2">
50+
<div className="flow-root mx-4 my-2">
5151
<button
5252
type="button"
5353
onClick={() => hasContent && setIsOpen((prev) => !prev)}

src/components/TurnChangesSummary.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export const TurnChangesSummary = memo(function TurnChangesSummary({
136136
}, []);
137137

138138
return (
139-
<div className="mx-4 my-2 animate-in fade-in slide-in-from-bottom-1 duration-300">
139+
<div className="flow-root mx-4 my-2 animate-in fade-in slide-in-from-bottom-1 duration-300">
140140
{/* Collapsed header bar */}
141141
<button
142142
type="button"

src/hooks/useAppOrchestrator.ts

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -478,49 +478,55 @@ export function useAppOrchestrator() {
478478
localStorage.setItem(LAST_SESSION_KEY, JSON.stringify(map));
479479
}, [manager.activeSessionId, manager.isDraft, manager.sessions, projectManager.projects, readLastSessionMap]);
480480

481-
// When activeSpaceId changes, switch to last used session in that space
481+
// When activeSpaceId changes, switch to last used session in that space.
482+
// Debounced by 60ms to coalesce rapid space switches and prevent race conditions
483+
// between concurrent switchSession/createSession calls.
482484
useEffect(() => {
483485
const prev = prevSpaceIdRef.current;
484486
const next = spaceManager.activeSpaceId;
485487
prevSpaceIdRef.current = next;
486488
if (prev === next) return;
487489

488-
// Find projects in the new space
489-
const spaceProjectIds = new Set(
490-
projectManager.projects
491-
.filter((p) => (p.spaceId || "default") === next)
492-
.map((p) => p.id),
493-
);
490+
const timer = setTimeout(() => {
491+
// Find projects in the new space
492+
const spaceProjectIds = new Set(
493+
projectManager.projects
494+
.filter((p) => (p.spaceId || "default") === next)
495+
.map((p) => p.id),
496+
);
494497

495-
// Check if current session is already in the new space
496-
if (manager.activeSession && spaceProjectIds.has(manager.activeSession.projectId)) {
497-
return; // Already in the right space
498-
}
498+
// Check if current session is already in the new space
499+
if (manager.activeSession && spaceProjectIds.has(manager.activeSession.projectId)) {
500+
return; // Already in the right space
501+
}
499502

500-
// Try to restore the last used session in this space
501-
const map = readLastSessionMap();
502-
const lastSessionId = map[next];
503-
if (lastSessionId) {
504-
const session = manager.sessions.find(
505-
(s) => s.id === lastSessionId && spaceProjectIds.has(s.projectId),
503+
// Try to restore the last used session in this space
504+
const map = readLastSessionMap();
505+
const lastSessionId = map[next];
506+
if (lastSessionId) {
507+
const session = manager.sessions.find(
508+
(s) => s.id === lastSessionId && spaceProjectIds.has(s.projectId),
509+
);
510+
if (session) {
511+
manager.switchSession(session.id);
512+
return;
513+
}
514+
}
515+
516+
// No remembered chat for this space: open a fresh draft chat in the space.
517+
// If the space has no projects, we can't create a draft chat yet.
518+
const firstProjectInSpace = projectManager.projects.find(
519+
(p) => (p.spaceId || "default") === next,
506520
);
507-
if (session) {
508-
manager.switchSession(session.id);
509-
return;
521+
if (firstProjectInSpace) {
522+
void handleNewChat(firstProjectInSpace.id);
523+
} else {
524+
// No projects in this space — deselect
525+
manager.deselectSession();
510526
}
511-
}
527+
}, 60);
512528

513-
// No remembered chat for this space: open a fresh draft chat in the space.
514-
// If the space has no projects, we can't create a draft chat yet.
515-
const firstProjectInSpace = projectManager.projects.find(
516-
(p) => (p.spaceId || "default") === next,
517-
);
518-
if (firstProjectInSpace) {
519-
void handleNewChat(firstProjectInSpace.id);
520-
} else {
521-
// No projects in this space — deselect
522-
manager.deselectSession();
523-
}
529+
return () => clearTimeout(timer);
524530
}, [spaceManager.activeSpaceId]); // eslint-disable-line react-hooks/exhaustive-deps
525531

526532
// Sync model from loaded session (canonical runtime names -> picker values)

src/hooks/useEngineBase.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ export function useEngineBase({
7373

7474
// Reset state when sessionId changes, restoring background state if available
7575
useEffect(() => {
76+
// Cancel any pending rAF flush from the previous session to prevent stale
77+
// streaming data from overwriting the new session's messages.
78+
cancelAnimationFrame(rafId.current);
79+
pendingFlush.current = false;
80+
7681
setMessages(initialMessages ?? []);
7782
if (initialMeta) {
7883
setIsProcessing(initialMeta.isProcessing);

0 commit comments

Comments
 (0)