Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4bb8963
feat(shell): stream workspace changes into the explorer
rohitg00 Aug 13, 2026
47761a6
feat(shell): system-level workspace watch behind shell::changed
rohitg00 Aug 13, 2026
db62869
feat(shell): live follow opens as a diff, deep noise filter
rohitg00 Aug 13, 2026
1d4b563
fix(shell): created files reach the live diff as creations
rohitg00 Aug 13, 2026
51d90f2
fix(shell): temp artifacts never steal the live follow
rohitg00 Aug 13, 2026
f4db8fd
docs(shell): document the shell::changed trigger type
rohitg00 Aug 13, 2026
e7b1019
feat(shell): review feed, lazy deep folders, nested-repo diffs, noise…
rohitg00 Aug 13, 2026
560c10b
feat(shell): line-count chips and totals in the changes feed
rohitg00 Aug 13, 2026
d9dec5e
feat(shell): burst-grouped changes feed
rohitg00 Aug 13, 2026
990a897
feat(shell): own diff renderer with folds, syntax color, and dir-awar…
rohitg00 Aug 13, 2026
f7d2eb2
fix(shell): own atomic-write temps never stream
rohitg00 Aug 13, 2026
f29bda3
feat(shell): diff pane matches the reference rendering
rohitg00 Aug 13, 2026
c479c6d
feat(shell): consume the open-in-shell deep link
rohitg00 Aug 13, 2026
de92f68
fix(shell): jailed watch paths, unstallable fan-out, bounded diffs
rohitg00 Aug 13, 2026
28e8049
(MOT-4407) feat(shell): polish live explorer review UI
rohitg00 Aug 13, 2026
5f1039d
(MOT-4407) feat(shell): add Codex-style workspace review
rohitg00 Aug 13, 2026
76cd044
(MOT-4407) feat(shell): edit worktree diffs safely
rohitg00 Aug 13, 2026
3d0d689
(MOT-4407) merge main into shell live explorer
rohitg00 Aug 13, 2026
1827dd0
(MOT-4407) docs(shell): add post-merge live review screenshot
rohitg00 Aug 13, 2026
b7766c4
(MOT-4407) fix(shell): keep explorer synced to chat root
rohitg00 Aug 13, 2026
303573c
(MOT-4407) fix(shell): retain exact working directory roots
rohitg00 Aug 13, 2026
62c01f5
(MOT-4407) fix(shell): avoid replaying collapse all
rohitg00 Aug 13, 2026
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
2 changes: 1 addition & 1 deletion console/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"@fontsource/geist-mono": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@lexical/react": "^0.44.0",
"@pierre/diffs": "^1.2.7",
"@pierre/diffs": "^1.3.5",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.19",
"@radix-ui/react-select": "^2.2.6",
Expand Down
1 change: 1 addition & 0 deletions console/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ function ScreenBody({
onRequestClose={onClose}
onMissing={onExtMissing}
workingDir={active?.workingDir ?? null}
conversationId={active?.id ?? null}
/>
)
}
Expand Down
34 changes: 31 additions & 3 deletions console/web/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ import {
mergeFiredTriggers,
type SessionTriggerInfo,
} from '@/lib/backend/triggers'
import { copyTextToClipboard } from '@/lib/clipboard'
import type {
ApprovalStreamEvent,
CompactResult,
QueuedMessagePreview,
} from '@/lib/backend/types'
import { copyTextToClipboard } from '@/lib/clipboard'
import { useConversationsCtxOptional } from '@/lib/conversations-context'
import { syncEditorWorkspace } from '@/lib/editor-sync'
import { expandFileMentions, parseFileMentions } from '@/lib/file-mentions'
Expand All @@ -47,7 +47,7 @@ import {
summaryLabel,
} from '@/lib/pdf-attachments'
import { newMessageId } from '@/lib/session-id'
import { useExtSessionChips } from '@/lib/ui-slots'
import { useExtSessionChips, useExtSessionTurnSummaries } from '@/lib/ui-slots'
import { cn } from '@/lib/utils'
import { fetchDefaultWorkingDir, validateWorkspaceDir } from '@/lib/working-dir'
import {
Expand Down Expand Up @@ -671,6 +671,24 @@ export function ChatView({
(chip) => chip.id === 'context',
)

/* Injected turn summaries live beside the composer rather than in the
* transcript. Workers own their data and subscribe by session id; the host
* only gives them the active turn state. */
const extSessionTurnSummaries = useExtSessionTurnSummaries()
const sessionTurnSummaries = useMemo(() => {
if (extSessionTurnSummaries.length === 0) return null
return [...extSessionTurnSummaries].sort(compareChips).map((summary) => {
const Summary = summary.render
return (
<Summary
key={summary.id}
sessionId={conversation.id}
isStreaming={streamingIndicator}
/>
)
})
}, [extSessionTurnSummaries, conversation.id, streamingIndicator])

/* Shared live region: SR announcements for auto-accept, stop-reason
* notices, and compaction markers route through this hook. Sighted
* users see the same messages in the transcript; visually-impaired
Expand Down Expand Up @@ -1754,7 +1772,9 @@ export function ChatView({
type="button"
onClick={handleCopySessionId}
aria-label={
copied ? `copied ${sessionId}` : `copy session id ${sessionId}`
copied
? `copied ${sessionId}`
: `copy session id ${sessionId}`
}
className="flex self-stretch items-center gap-1 text-ink-faint hover:text-ink transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Expand Down Expand Up @@ -1954,6 +1974,14 @@ export function ChatView({
) : null}
</section>
) : null}
{sessionTurnSummaries ? (
<div
className="flex flex-wrap items-center justify-end gap-1.5 px-1"
data-chat-turn-summary-slot
>
{sessionTurnSummaries}
</div>
) : null}
<Composer
mode={conversation.mode}
model={effectiveModel}
Expand Down
101 changes: 101 additions & 0 deletions console/web/src/components/ui/FileDiff.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FileDiff, resolveFileDiffEditState } from './FileDiff'

const { renderedProps } = vi.hoisted(() => ({
renderedProps: [] as Array<Record<string, unknown>>,
}))

vi.mock('@pierre/diffs', () => ({
DEFAULT_THEMES: { light: 'light', dark: 'dark' },
}))

vi.mock('@pierre/diffs/react', () => ({
EditProvider: ({ children }: { children?: React.ReactNode }) => (
<div data-edit-provider="true">{children}</div>
),
MultiFileDiff: (props: Record<string, unknown>) => {
renderedProps.push(props)
return <div data-edit={String(props.edit)} />
},
}))

vi.mock('@/hooks/use-theme', () => ({
useTheme: () => ['dark', vi.fn()],
}))

const SOURCE = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), 'FileDiff.tsx'),
'utf8',
)
const oldFile = { name: 'app.ts', contents: 'const value = 1\n' }
const newFile = { name: 'app.ts', contents: 'const value = 2\n' }

beforeEach(() => {
renderedProps.length = 0
})

describe('FileDiff', () => {
it('reports the requested editor lifecycle without treating read-only as loading', () => {
expect(resolveFileDiffEditState(false, false, false)).toBeNull()
expect(resolveFileDiffEditState(true, false, false)).toBe('loading')
expect(resolveFileDiffEditState(true, true, false)).toBe('ready')
expect(resolveFileDiffEditState(true, false, true)).toBe('error')
})

it('preserves the read-only default and existing diff options', () => {
const html = renderToStaticMarkup(
<FileDiff
oldFile={oldFile}
newFile={newFile}
diffStyle="split"
ignoreWhitespace
/>,
)

expect(html).toContain('data-edit="false"')
expect(html).not.toContain('data-edit-provider')
expect(html).not.toContain('Loading inline editor')
expect(renderedProps).toHaveLength(1)
expect(renderedProps[0]).toMatchObject({
oldFile,
newFile,
edit: false,
editorOptions: undefined,
options: {
diffStyle: 'split',
parseDiffOptions: { ignoreWhitespace: true },
themeType: 'dark',
},
})
})

it('keeps the diff read-only while an explicitly requested editor loads', () => {
const html = renderToStaticMarkup(
<FileDiff oldFile={oldFile} newFile={newFile} edit onChange={vi.fn()} />,
)

expect(html).toContain('data-edit="false"')
expect(html).toContain('role="status"')
expect(html).toContain('Loading inline editor')
expect(html).not.toContain('data-edit-provider')
})

it('loads Pierre editing only through the edit-gated dynamic import', () => {
const guard = SOURCE.indexOf('if (!edit || EditorConstructor) return')
const lazyImport = SOURCE.indexOf("import('@pierre/diffs/edit')")

expect(guard).toBeGreaterThan(-1)
expect(lazyImport).toBeGreaterThan(guard)
expect(SOURCE).toContain(
"import type { Editor, EditorOptions } from '@pierre/diffs/edit'",
)
expect(SOURCE).not.toMatch(
/^import \{ Editor(?:, EditorOptions)? \} from '@pierre\/diffs\/edit'$/m,
)
expect(SOURCE).toContain('onEditStateChange?.(editState)')
})
})
133 changes: 131 additions & 2 deletions console/web/src/components/ui/FileDiff.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
import { DEFAULT_THEMES } from '@pierre/diffs'
import { MultiFileDiff } from '@pierre/diffs/react'
import type { Editor, EditorOptions } from '@pierre/diffs/edit'
import { EditProvider, MultiFileDiff } from '@pierre/diffs/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTheme } from '@/hooks/use-theme'

type PierreEditorConstructor = new (
options?: EditorOptions<undefined>,
) => Editor<undefined>

export type FileDiffEditState = 'loading' | 'ready' | 'error'

export function resolveFileDiffEditState(
edit: boolean,
editorReady: boolean,
editLoadFailed: boolean,
): FileDiffEditState | null {
if (!edit) return null
if (editorReady) return 'ready'
return editLoadFailed ? 'error' : 'loading'
}

/** One side of the diff — a whole file's text, not a patch. */
export interface FileDiffSide {
/** Display name; also infers the syntax-highlight language. */
Expand All @@ -16,6 +34,22 @@ export interface FileDiffProps {
diffStyle?: 'unified' | 'split'
/** Long lines wrap by default; `'scroll'` preserves strict columns. */
overflow?: 'scroll' | 'wrap'
/** Intraline emphasis. `'none'` keeps only whole-line highlighting. */
lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'
/** Ignore leading/trailing whitespace when computing changed lines. */
ignoreWhitespace?: boolean
/** Render the file body folded; useful for collapse-all review controls. */
collapsed?: boolean
/** Expand every unchanged line instead of the compact hunk view. */
expandUnchanged?: boolean
/** Hide Pierre's file header when the caller supplies its own review row. */
disableFileHeader?: boolean
/** Enable direct editing of the new-file side. The editor loads lazily. */
edit?: boolean
/** Receives the complete current new-file body after each edit. */
onChange?(contents: string): void
/** Reports whether the lazily loaded inline editor can accept input. */
onEditStateChange?(state: FileDiffEditState): void
className?: string
}

Expand All @@ -32,20 +66,115 @@ export function FileDiff({
newFile,
diffStyle = 'unified',
overflow = 'wrap',
lineDiffType = 'word-alt',
ignoreWhitespace = false,
collapsed = false,
expandUnchanged = false,
disableFileHeader = false,
edit = false,
onChange,
onEditStateChange,
className,
}: FileDiffProps) {
const [theme] = useTheme()
return (
const [EditorConstructor, setEditorConstructor] =
useState<PierreEditorConstructor | null>(null)
const [editLoadFailed, setEditLoadFailed] = useState(false)
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange

useEffect(() => {
if (!edit || EditorConstructor) return

let disposed = false
setEditLoadFailed(false)
void import('@pierre/diffs/edit')
.then(({ Editor }) => {
if (!disposed) setEditorConstructor(() => Editor)
})
.catch((error) => {
if (disposed) return
setEditLoadFailed(true)
console.warn(
'[console] Pierre editor failed to load; keeping the diff read-only',
error,
)
})

return () => {
disposed = true
}
}, [edit, EditorConstructor])

useEffect(() => {
if (!edit) setEditLoadFailed(false)
}, [edit])

const editorOptions = useMemo<EditorOptions<undefined>>(
() => ({
onChange(file) {
onChangeRef.current?.(file.contents)
},
}),
[],
)
const createEditor = useCallback(
(options: EditorOptions<undefined>) => {
if (!EditorConstructor) {
throw new Error('Pierre editor is not loaded')
}
return new EditorConstructor(options)
},
[EditorConstructor],
)
const editState = resolveFileDiffEditState(
edit,
EditorConstructor !== null,
editLoadFailed,
)
useEffect(() => {
if (editState !== null) onEditStateChange?.(editState)
}, [editState, onEditStateChange])
const editing = editState === 'ready'

const diff = (
<MultiFileDiff
oldFile={oldFile}
newFile={newFile}
edit={editing}
editorOptions={editing ? editorOptions : undefined}
className={className}
options={{
diffStyle,
overflow,
lineDiffType,
collapsed,
expandUnchanged,
disableFileHeader,
parseDiffOptions: { ignoreWhitespace },
theme: DEFAULT_THEMES,
themeType: theme,
}}
/>
)

if (editing) {
return <EditProvider createEditor={createEditor}>{diff}</EditProvider>
}

return (
<>
{diff}
{edit && (
<span
role={editState === 'error' ? 'alert' : 'status'}
className="sr-only"
>
{editState === 'error'
? 'Inline editing is unavailable; showing a read-only diff.'
: 'Loading inline editor.'}
</span>
)}
</>
)
}
11 changes: 9 additions & 2 deletions console/web/src/demo/stubs/pierre-diffs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Build stub for `@pierre/diffs` (and `/react`), aliased in only by
* Build stub for `@pierre/diffs` (and `/react` + `/edit`), aliased in only by
* `vite.demo.config.ts`.
*
* The package pulls all of shiki's grammars, which lands ~13MB of language
Expand All @@ -15,6 +15,13 @@ export interface FileContents {
contents: string
}

/** Keep the demo build free of Pierre's editor chunk as well. */
export class Editor {}

export function EditProvider({ children }: { children?: React.ReactNode }) {
return <>{children}</>
}

/** The body, monospaced and scrollable, with no tokenizer behind it. */
function PlainFile({ contents }: { contents: string }) {
return (
Expand All @@ -33,4 +40,4 @@ export function MultiFileDiff({ newFile }: { newFile: FileContents }) {
return <PlainFile contents={newFile.contents} />
}

export default { DEFAULT_THEMES, File, MultiFileDiff }
export default { DEFAULT_THEMES, EditProvider, Editor, File, MultiFileDiff }
Loading
Loading