Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions .agents/docs/sessions-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,30 @@ this page is the full text of the rules summarised there.
`buildConversationMarkdown` (`packages/shared/src/conversation-markdown.ts`),
NOT `buildReplayPromptFromHistory` — that one is the agent-facing replay
prompt and its budget behaviour is load-bearing for CLI resume; keep the two
separate. The copy targets ~20k estimated tokens AND 50k characters (both
separate. The copy targets ~60k estimated tokens AND 150k characters (both
bounds, because CJK is ~1 token/char) and gets there by degrading tool
output, terminal output, then thinking, oldest turns first.
**Message text is never trimmed** — a conversation whose prose alone exceeds
the budget returns `overBudget` instead of cutting it. Whatever was trimmed
must reach the toast (`describeCopiedConversation`); silent truncation reads
as "I copied everything".
results, terminal output, then collapsing tool calls to per-turn counted
summaries, and only then capping thinking — oldest turns first.
**Message text is never trimmed and thinking is never dropped** — the export
exists to move context to another agent, so prose-shaped content outranks
everything the agent produced around it. Thinking degrades by capping (head +
tail, middle elided) so the receiving conversation always inherits some of the
reasoning; a conversation whose prose alone exceeds the budget returns
`overBudget` instead of cutting it. Whatever was trimmed must reach the toast
(`describeCopiedConversation`); silent truncation reads as "I copied
everything". The one edit allowed on prose is heading level: bodies routinely
contain `#`/`##` headings that would outrank the `##` turn headings and
destroy the outline, so `demoteMarkdownHeadings` shifts them down two levels
outside fenced blocks. Turn headings carry a round number, local time, and —
for assistant turns — the recorded model and effective working time; speaker
names appear only when the conversation has more than one human in it.
The trim notice lives in the header blockquote, not the footer, so a reader
learns the transcript is incomplete before reading it rather than after.
Message fork menus copy the inclusive history prefix independently of ACP fork
capability. Missing boundaries fail; generating replies are marked as partial.
capability. Missing boundaries fail. A reply that was still generating is marked
through the builder's `incompleteFinalResponse` option, so the warning lands in
the header blockquote with the trim notice rather than as a trailing line —
callers must not append their own note after the transcript.
Attachment bytes are not exported. The session header copies all history.
Header "Open in" / "Copy Path" launchers live here; shared launcher/path
helpers are `../../lib/session-path-launchers.ts`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Rank thinking above tool detail in the Markdown export

Status: implemented
Translation: pending

## Abstract

Copy as Markdown is how a conversation reaches another agent, machine or
workspace when native forking is unavailable, but its degradation ladder was
ordered as if the export were a transcript: thinking was dropped one level
before tool calls were touched, and the floor spent up to 120 characters per
call on bold, mid-word-truncated command strings whose results were already
gone. Prose headings inside message bodies also outranked the `## User` /
`## Assistant` headings, so long exports lost their turn structure entirely.
Thinking is now capped rather than dropped, tool calls collapse to one counted
per-turn summary before that happens, body headings are demoted, turns carry
round numbers and provenance, and the budget rose to 150k characters / 60k
tokens. The character caps and the raised budget are product judgements tuned
against synthetic fixtures, not measurements of real sessions.

## Decision

The export's purpose sets the value order. Its job is to move a conversation's
CONTEXT somewhere else, so prose-shaped content — message text, proposed plans,
and the agent's reasoning — outranks everything produced around it. The old
ladder encoded the opposite for thinking.

Four changes in `packages/shared/src/conversation-markdown.ts`:

- `LevelConfig.includeThinking: boolean` became `thinkingCap: number`. Thinking
degrades through the same `clampMiddle` as tool results, so the head and the
conclusion survive and the receiving conversation always inherits some of the
reasoning. A binary drop was rejected: it is the one content class where
losing the middle is clearly better than losing the whole.
- Tool calls collapse one level earlier than thinking is capped, and collapse to
a single `<details>` per turn with counts by `toolName`. Simply swapping the
two old levels was considered and is strictly worse: the old floor's per-call
lines were themselves a large share of the budget, so aggregating first is
what buys the room that keeps most sessions off levels 5 and 6 altogether.
- `demoteMarkdownHeadings` shifts ATX headings in message bodies down two levels
outside fenced blocks. This is an edit to prose, which the file's own
invariant forbids, and the invariant was narrowed rather than waived: no
characters are removed, only `#` markers added. The alternative — abandoning
headings as turn delimiters — loses the document outline that makes a long
paste navigable.
- Turn headings carry a round number, local `MM-DD HH:mm`, and for assistant
turns the recorded model and effective working time; a rule opens each new
round; the header blockquote carries the time range, repo/branch, models, and
the trim notice. The notice moved from footer to header because its reader is
often another agent, which needs to know the transcript is incomplete before
reading it. Speaker names reach turn headings only when the conversation has
more than one distinct `userId`; repeating one name through a solo session is
noise, and the caller supplies the `userId → name` map because `@lody/shared`
cannot resolve it.

The same reasoning later absorbed the "last response was still generating"
warning that #558 appended after the document: it is a caller-localized string
passed as `incompleteFinalResponse` and rendered in the header block. Two notices
about the transcript's completeness sitting in different places, one of them
after the content it qualifies, is the defect this change set exists to remove.

`describeTrim` states that collapsing drops results and terminal output rather
than incrementing the per-block tallies. A collapsed call never reaches the
block renderer, so those counters stay at zero; faking them to make the notice
accurate would have made the stats lie instead.

The budget moved from 50k/20k characters/tokens to 150k/60k. The old ceiling
predates current context windows and was the reason ordinary sessions reached
the floor at all. Secret redaction still covers terminal output and tool results
only, and deliberately not message text: a user forking a conversation wants
the material they pasted into it to travel with them.

`buildReplayPromptFromHistory` is untouched. It is the agent-facing replay
prompt, its budget behaviour is load-bearing for CLI resume, and it keeps its
own `thinkingOmitted` semantics.

## Evidence and limits

`packages/shared/tests/conversation-markdown.test.ts` covers the new behaviour
at 24 assertions, including: heading demotion leaving fenced `# comment` lines
intact and clamping at h6; collapse reached with thinking untouched; thinking
capped with `characters elided` and the block still present at the floor; round
numbering with exactly one rule per new round; model and `4m12s` working time
derived from `endedAt - timestamp - permissionWaitMs`; speaker names present
with two `userId`s and absent with one; and the trim notice preceding the first
turn. The squeezed-budget tests drive `maxChars` directly rather than building
fixtures large enough to trip the real ceiling.

`pnpm --filter @lody/shared --filter @lody/components run typecheck` passes.
Verification is fixture-based: no real session was exported and pasted into
another agent, so the claim that the new header and per-turn metadata help a
receiving model is reasoned, not measured. The 2000/500 thinking caps and the
150k/60k budget are starting values chosen against those fixtures and should be
revisited against real session data.

`.agents/docs/sessions-surface.md` holds the full rule text; the binding
one-liner stays in `packages/components/src/components/sessions/AGENTS.md`.
No Spec owns this surface today, so none was updated.
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Shrink the component test module graph

Status: implemented
Translation: pending

## Abstract

The `@lody/components` suite takes roughly ten minutes because Vitest isolates
every one of its 446 files and re-evaluates each file's whole import graph;
assertions themselves account for well under a fifth of the wall clock. Tracing
`vite-node` showed a single menu test pulling 991 modules, almost none of them
from `node_modules`, so four sources of accidental graph weight were removed: an
unused `Calendar` re-export dragging `react-day-picker`, `date-fns/locale`
barrel imports, an eagerly globbed directory of 324 icon SVGs, and two Vite
plugins plus a catch-all `tsconfig` path mapping that are only needed by the
production build. That test now loads 667 modules. The wall-clock effect is
**not** established: run-to-run variance on the measuring host exceeded the
effect being measured, and the target of a 20-second suite is unreachable
without disabling isolation, which currently crashes on pending async work.

## Decision

Vitest's `collect` phase — importing a test file and evaluating its transitive
graph — dominates this suite. Isolation means that work repeats per file, so the
only lever available without touching isolation is the size of the graph itself.
Four independent sources were found by ranking `DEBUG=vite-node:*` requests:

- `src/ui/index.ts` re-exported `src/ui/calendar.tsx`, whose `react-day-picker`
dependency pulls `date-fns`, `date-fns-jalali` and `@date-fns/tz`. The
`Calendar` component had no callers anywhere in the repository. Deleted, along
with the `react-day-picker` dependency; this also removes 3.6 MB from the
product bundle.
- Eight modules imported `{ enUS, zhCN }` from the `date-fns/locale` barrel,
which natively loads all 96 locales. They now deep-import
`date-fns/locale/en-US` and `date-fns/locale/zh-CN`, both declared exports.
- `new URL(\`./files/${name}.svg\`, import.meta.url)` makes Vite eagerly glob the
whole icon directory — 324 SVG modules — into every consumer of `FileIcon`.
That is correct for a bundle, which has to emit the assets anyway, so the two
calls moved to `file-icons/asset-url.ts` and the test config aliases only that
module to a stub. Stubbing the whole `file-icons` entry was rejected because
`file-tree-virtual-rows.test.tsx` exercises its component cache for real.
- The test config dropped `vite-plugin-top-level-await` (Node and Vitest support
top-level await natively; the loro WASM suites pass without it) and replaced
`vite-tsconfig-paths` with explicit aliases, because `tsconfig`'s catch-all
`"*": ["./*"]` mapping makes the plugin probe the filesystem for every bare
specifier in every module. `vite.config.ts` keeps both: the production bundle
does need TLA downlevelling, and its targets are not this file's concern.

Disabling isolation is the only change that would reach a 20-second suite, since
it turns 446 graph evaluations into one per worker. It was measured and rejected
for now: with `isolate: false` a run succeeds up to six files and fails at seven
regardless of which file is seventh, with tinypool reporting
`Unhandled Rejection: Terminating worker thread`. Raising the worker heap to 8 GB
does not help, so this is leaked pending async work that isolation currently
contains, not memory pressure. 89 of the 446 files also use `vi.mock`, whose
registry is shared once isolation is off. Both are per-file cleanup projects.

## Evidence and limits

Module counts are deterministic and were taken from `DEBUG=vite-node:*` on
`tests/session-header-menu.test.tsx`: 991 requests before, 667 after, with
`date-fns` modules down to 3 and `react-day-picker` to 0. Of the original 991,
only 11 came from `node_modules`, confirming externalisation was already working
and that the weight was first-party. jsdom was ruled out as a cause: a single
file spends about 1.2 s in `environment`.

**Wall-clock improvement is unverified.** Timings on the measuring host are not
reproducible at the needed resolution — the same baseline configuration measured
97 s cold and 128 s warm on an identical 112-file shard, a spread wider than any
effect claimed here. Earlier single-file figures in this work were taken across
config changes that invalidate the Vite cache and should not be treated as
evidence. A trustworthy number needs repeated runs on a quiet host.

The full suite never completed during this work; three attempts were terminated
by the environment's background-task limit. Correctness was checked on a
`--shard=1/4` run (112 files, 912 tests, all passing) plus the tests touching the
changed modules, and `typecheck` and `lint` pass repository-wide. The suite also
requires `NODE_ENV=test`: under `NODE_ENV=production` React resolves to a build
that does not export `act`, and 154 files fail for that reason alone.

Vitest 3.2.4 is pinned here; `fsModuleCache` and `vitest doctor`, both relevant
to this problem, arrive in Vitest 4, which parts of the workspace already use.
11 changes: 11 additions & 0 deletions packages/components/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ mobile surfaces. Background for the rules below:
intentionally public or narrowly token-scoped DTO.
- Renderer and worker builds that cannot use native top-level await must use
`vite-top-level-await-fixed.ts`. Do not bypass its audited-version assertion.
- Keep accidental weight out of the test module graph: Vitest re-evaluates a
file's whole import graph per test file. Do not re-export a heavy leaf from
`src/ui/index.ts`, deep-import `date-fns/locale/<tag>` rather than the barrel,
and keep the icon-asset `new URL` glob alone in
`src/components/icons/file-icons/asset-url.ts` so the test alias can replace
it. Rationale and measurements:
[module graph note](../../.agents/notes/implemented/testing/2026-09-10-components-test-module-graph.md).
- `vitest.config.ts` deliberately omits `vite-plugin-top-level-await` and
`vite-tsconfig-paths`; `vite.config.ts` keeps both because the product bundle
needs them. Run the suite with `NODE_ENV=test` — a `production` value resolves
React to a build without `act`.
- System theme state, persistence, and browser preference tracking are owned by
`next-themes`. Keep Lody's wrapper focused on preview state, fixed VS Code theme
application, and the Electron native-theme bridge.
Expand Down
1 change: 0 additions & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,6 @@
"prosemirror-flat-list": "^0.7.1",
"qrcode": "^1.5.4",
"react": "catalog:",
"react-day-picker": "^9.8.0",
"react-dom": "catalog:",
"react-hook-form": "^7.60.0",
"react-i18next": "^15.6.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useCallback, type ReactNode } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { formatDistance, type Locale } from 'date-fns';
import { enUS, zhCN } from 'date-fns/locale';
import { enUS } from 'date-fns/locale/en-US';
import { zhCN } from 'date-fns/locale/zh-CN';
import { ExternalLink, Loader2, TimerReset } from 'lucide-react';

import { Badge } from '@/ui/badge';
Expand Down
21 changes: 21 additions & 0 deletions packages/components/src/components/icons/file-icons/asset-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Asset URLs for the file-type icon SVGs.
*
* `new URL(<template containing a variable>, import.meta.url)` is how Vite emits
* an asset whose name is only known at runtime — but it gets there by eagerly
* globbing the whole directory into the module graph. That is 324 extra modules
* dragged in by every consumer of `FileIcon`, which is fine for a bundle (they
* have to be emitted anyway) and ruinous under Vitest, where the graph is
* re-evaluated for every test file.
*
* Keeping the two `new URL` calls alone in this module lets the test config
* alias it to a stub without also stubbing the extension mapping or the cached
* component factory, both of which real tests do exercise. Do not add anything
* else here, and do not inline these back into `index.tsx`.
*/

export const getFileIconUrl = (iconName: string): string =>
new URL(`./files/${iconName}.svg`, import.meta.url).href;

export const getFolderIconUrl = (iconName: string): string =>
new URL(`./folders/${iconName}.svg`, import.meta.url).href;
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useMemo, type ComponentType } from 'react';
import { getFileIconUrl, getFolderIconUrl } from '@/components/icons/file-icons/asset-url';
import {
compoundExtensionMap,
extensionMap,
Expand Down Expand Up @@ -70,14 +71,6 @@ interface FolderIconProps {
className?: string;
}

// Create icon URL from icon name
const getFileIconUrl = (iconName: string): string => {
return new URL(`./files/${iconName}.svg`, import.meta.url).href;
};

const getFolderIconUrl = (iconName: string): string => {
return new URL(`./folders/${iconName}.svg`, import.meta.url).href;
};

export const FileIcon = ({ filePath, className = 'h-4 w-4' }: FileIconProps) => {
const iconUrl = useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { Locale } from 'date-fns';
import { enUS, zhCN } from 'date-fns/locale';
import { enUS } from 'date-fns/locale/en-US';
import { zhCN } from 'date-fns/locale/zh-CN';
import { formatDistanceToNow } from 'date-fns';
import { AlertCircle, Check, Download, Loader2, RefreshCw, X } from 'lucide-react';
import type { LocalProjectHistoryCatalogItem, LocalProjectHistoryProvider } from '@lody/shared';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AnimatePresence, motion } from 'framer-motion';
import { formatDistanceToNow, type Locale } from 'date-fns';
import { enUS, zhCN } from 'date-fns/locale';
import { enUS } from 'date-fns/locale/en-US';
import { zhCN } from 'date-fns/locale/zh-CN';
import {
AlertCircle,
Boxes,
Expand Down
6 changes: 3 additions & 3 deletions packages/components/src/components/sessions/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ changing those files.

- Read receipts are gated on VISIBILITY, not on being mounted: keep the
explicit per-surface `isVisible` prop.
- Markdown copy uses `buildConversationMarkdown`, never `buildReplayPromptFromHistory`;
preserve prose and toast omissions. Fork-menu copy includes the selected message,
independent of ACP fork support.
- Markdown copy uses `buildConversationMarkdown`, not `buildReplayPromptFromHistory`:
keep prose whole, cap thinking rather than drop it, toast every omission, and
include the selected message regardless of ACP fork support.
- Read ACP capabilities via `useResolvedMachineMeta()` and selectors via
`useSessionAcpSelectorContext()`; the controlled composer must not recompute
selector options.
Expand Down
Loading
Loading