Files
windmill/frontend/src/lib/components/sessions/diffTree.test.ts
T
GuilhemandClaude Opus 4.8 a6c0b3756b feat: chat-scoped session changes bar + unified diff drawer (#9762)
* feat(frontend): chat-scoped unified session changes bar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): drop diff-baseline toggle, show natural per-row diffs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): carry Draft marker to expanded raw-app file rows

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): show raw-app Draft badge once at tree root, not per file

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(frontend): reuse shared DraftBadge in session diff drawer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): show draft-author avatars in session diff badge, icon-only in sidebar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): keep badge pill around avatar in icon-only DraftBadge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): small draft marker = indigo pen + avatar; correct itemKind label

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(frontend): drop package-lock churn from merge (match origin/main)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): dedup diff-button count for legacy fork sessions; test mask helper

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): collapsible session diff panel + per-row open-diff action

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): shared sessionDeployModel for review & deploy (S1)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): model-driven session review drawer, deploy inert (S2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): wire session deploy + on-behalf/conflict gating (S3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): behind banner + Update fork + deployment request (S4)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): session changes dock opens drawer by filter (S5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): session review UI polish (badge, dock, In parent, tree width)

- draft rows show only the avatar DraftBadge, not a duplicate state pill
- drop redundant dock Review button (same as "N to review")
- rename Done -> In parent with a "deployed in parent workspace" tooltip
- widen the file tree

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): existence-gate In parent rows; Badge filters; badge hover

- drop discarded mask-only items from the In-parent segment (existence check)
- use the Badge component for the drawer filter segments and the changes dock
- soften the blue Badge hover (blue-50 base was jumping to blue-200)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): polish session diff drawer (layout, badges, actions)

- remove empty fork-banner gap; uniform sidebar tree padding
- full-bleed diff list: drop card borders/side padding, separators between items
- clamp tree x-overflow; right-align tree badges (min-w-0 on the row button)
- brand-compliant selected filter badges; smaller draft badge
- hide per-row open-diff button when the panel is open
- rename "Delete draft" to "Discard draft" (destructive); remove header Review button
- larger sm deploy/discard action buttons; remove per-item diff-content collapse

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): session Edits dock — deploy gating + change-op tracking

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(frontend): session bar per-status badges; drop change-op tracking

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(frontend): unwrap raw apps into per-file tree in session diff sidebar

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(frontend): dot-parcours pipeline (badge-derived, melt tooltip) + discard confirm

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): behind-only session item reads as deployed, not bare

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(frontend): even sidebar tree margins; gutter-aware right padding

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): pass chat id as from_session; wire deploying flag

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): session diff drawer scroll-to-flush, ordering, spacer, deploy gating

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): preserve chat mask on compact; guard stale existence checks; clear poll timers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): refresh bar after drawer deploys; conflict hint over chip; plain conflict badge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(frontend): diff drawer card layout with flash ring and aligned insets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): hide stale deployed chip once row status badge reads deployed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(frontend): session dock to two states; drop parent deploy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(frontend): staged deploy animation in session edits drawer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(frontend): stale-draft warning in session edits drawer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): keep chat mask honest on deploy and discard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): map trigger_email deploy kind; serialize mask persists

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): reset mask on new chat; close review-flagged races

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(frontend): rename session drawer title to Edited during session

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): keep mask persist queue alive after a failed save

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): sync session chatId on chat rotation; gate deploy on canWrite

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(frontend): keep compare handoff for deletion-only session edits

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): hold deploy success beat across re-keyed rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:23:54 +02:00

166 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest'
import { buildDiffTree, type AppRootMeta, type DiffTreeItem, type NavEntry } from './diffTree'
// A diff item identified by its storage `key` but placed in the tree by its
// friendly `structurePath` — mirroring how the drawer feeds raw-app files.
function item(key: string, structurePath: string): DiffTreeItem<string> {
return { key, structurePath, data: key }
}
const noApps = new Map<string, AppRootMeta>()
const allOpen = () => true
function orderKeys(model: ReturnType<typeof buildDiffTree<string>>, isOpen = allOpen) {
return model.order(isOpen).map((e) => e.key)
}
describe('buildDiffTree — structure', () => {
it('groups items by their 2-segment scope', () => {
const t = buildDiffTree([item('a', 'u/admin/foo'), item('b', 'u/admin/bar')], noApps)
expect(t.root.children).toHaveLength(1)
const scope = t.root.children[0]
expect(scope.type).toBe('folder')
if (scope.type !== 'folder') return
expect(scope.fullPath).toBe('u/admin')
expect(scope.isScope).toBe(true)
expect(scope.children.map((c) => c.type)).toEqual(['file', 'file'])
})
it('nests deeper segments as folders and sorts folders-first then alphabetically', () => {
const t = buildDiffTree(
[item('z', 'u/admin/zeta'), item('a', 'u/admin/alpha'), item('s', 'u/admin/sub/leaf')],
noApps
)
const scope = t.root.children[0]
if (scope.type !== 'folder') throw new Error('expected scope folder')
// folder ('sub') before files, files alphabetical
expect(scope.children.map((c) => `${c.type}:${c.name}`)).toEqual([
'folder:sub',
'file:alpha',
'file:zeta'
])
})
it('keeps a single-segment item at the root with no scope', () => {
const t = buildDiffTree([item('orphan', 'orphan')], noApps)
expect(t.root.children).toHaveLength(1)
expect(t.root.children[0]).toMatchObject({ type: 'file', name: 'orphan', key: 'orphan' })
})
it('app roots sort as items among leaves — same position before and after unwrap', () => {
const apps = new Map<string, AppRootMeta>([['u/admin/beta_app', { summaryKey: 'raw_app/b' }]])
// Unloaded: the app is a plain leaf between alpha and zeta.
const before = buildDiffTree(
[
item('a', 'u/admin/alpha'),
item('raw_app/b', 'u/admin/beta_app'),
item('z', 'u/admin/zeta')
],
apps
)
const scopeBefore = before.root.children[0]
if (scopeBefore.type !== 'folder') throw new Error('expected scope')
expect(scopeBefore.children.map((c) => `${c.type}:${c.name}`)).toEqual([
'file:alpha',
'file:beta_app',
'file:zeta'
])
// Loaded: the leaf is replaced by the app-root folder — same position,
// not hoisted to the folder group.
const after = buildDiffTree(
[
item('a', 'u/admin/alpha'),
item('f', 'u/admin/beta_app/App.tsx'),
item('z', 'u/admin/zeta')
],
apps
)
const scopeAfter = after.root.children[0]
if (scopeAfter.type !== 'folder') throw new Error('expected scope')
expect(scopeAfter.children.map((c) => `${c.type}:${c.name}`)).toEqual([
'file:alpha',
'folder:beta_app',
'file:zeta'
])
})
it('tags the folder matching an app root and leaves others untagged', () => {
const apps = new Map<string, AppRootMeta>([
['u/admin/my_app', { summaryKey: 'raw_app/x', summary: 'My app' }]
])
const t = buildDiffTree(
[item('f1', 'u/admin/my_app/App.tsx'), item('f2', 'u/admin/other/x.ts')],
apps
)
const scope = t.root.children[0]
if (scope.type !== 'folder') throw new Error('expected scope')
const myApp = scope.children.find((c) => c.type === 'folder' && c.fullPath === 'u/admin/my_app')
const other = scope.children.find((c) => c.type === 'folder' && c.fullPath === 'u/admin/other')
expect(myApp && myApp.type === 'folder' && myApp.app?.summary).toBe('My app')
expect(other && other.type === 'folder' && other.app).toBeUndefined()
})
})
describe('buildDiffTree — order(isOpen)', () => {
const t = buildDiffTree(
[item('a', 'u/admin/app/App.tsx'), item('b', 'u/admin/app/index.css')],
new Map([['u/admin/app', { summaryKey: 'raw_app/app' }]])
)
it('lists scope → app folder → files when everything is open', () => {
expect(orderKeys(t)).toEqual(['folder:u/admin', 'folder:u/admin/app', 'a', 'b'])
})
it('hides a folder’s children when it is collapsed', () => {
const closedApp = (key: string) => key !== 'folder:u/admin/app'
expect(orderKeys(t, closedApp)).toEqual(['folder:u/admin', 'folder:u/admin/app'])
})
})
describe('buildDiffTree — parentKeyOf (ArrowLeft)', () => {
// The regression that shipped: a raw-app file keyed on its STORAGE path
// (`…/draft_<uuid>/…`) but placed by its FRIENDLY path. The parent must be the
// friendly folder it actually nests under — not anything derived from the key.
it('returns the friendly parent folder for a storage-keyed file', () => {
const t = buildDiffTree(
[item('raw_app_file/u/admin/draft_x/App.tsx', 'u/admin/my_app/App.tsx')],
noApps
)
expect(t.parentKeyOf('raw_app_file/u/admin/draft_x/App.tsx')).toBe('folder:u/admin/my_app')
})
it('returns the immediate folder for a deeper file', () => {
const t = buildDiffTree([item('k', 'u/admin/app/runnables/a')], noApps)
expect(t.parentKeyOf('k')).toBe('folder:u/admin/app/runnables')
expect(t.parentKeyOf('folder:u/admin/app/runnables')).toBe('folder:u/admin/app')
})
it('returns the scope folder for a file directly under it', () => {
const t = buildDiffTree([item('k', 'u/alice/script')], noApps)
expect(t.parentKeyOf('k')).toBe('folder:u/alice')
})
it('returns undefined for a scope folder and a root-level leaf', () => {
const t = buildDiffTree([item('k', 'u/admin/app/App.tsx'), item('o', 'orphan')], noApps)
expect(t.parentKeyOf('folder:u/admin')).toBeUndefined()
expect(t.parentKeyOf('o')).toBeUndefined()
})
})
describe('buildDiffTree — firstChildKeyOf (ArrowRight)', () => {
it('returns the first child key (folders sort first)', () => {
const t = buildDiffTree(
[item('file', 'u/admin/app/App.tsx'), item('run', 'u/admin/app/runnables/a')],
noApps
)
// 'runnables' folder sorts before the 'App.tsx' file
expect(t.firstChildKeyOf('folder:u/admin/app')).toBe('folder:u/admin/app/runnables')
expect(t.firstChildKeyOf('folder:u/admin/app/runnables')).toBe('run')
})
it('returns undefined for an unknown or empty folder', () => {
const t = buildDiffTree([item('k', 'u/admin/app/App.tsx')], noApps)
expect(t.firstChildKeyOf('folder:does/not/exist')).toBeUndefined()
})
})