Skip to content
Draft
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This repository is a template with sensible defaults for building Tauri React ap
## Core Rules

### Codex App Server schema

If you need to check some codex app-server related things, use "codex app-server generate-json-schema --out ./codex-schema" to generate schema and check local dir ./codex-schema for schemas.

### New Sessions
Expand Down
25 changes: 25 additions & 0 deletions docs/developer/architecture-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@ src-tauri/src/
├── main.rs # Entry point
├── chat/ # Session lifecycle management
│ ├── commands.rs # Tauri commands (send message, create session, image processing)
│ ├── checkpoints.rs # Git-backed restore points captured before/after chat runs
│ ├── claude.rs # Claude CLI process spawning and management
│ ├── codex.rs # Codex app-server turn execution and history parsing
│ ├── detached.rs # Detached process recovery (survives app quit via nohup)
│ ├── registry.rs # Active session registry
│ ├── storage.rs # Session data on disk
Expand Down Expand Up @@ -363,6 +365,29 @@ This includes:
3. **Update docs** - Document new patterns as they emerge
4. **Test comprehensively** - Use the established testing patterns

### Chat Restore Points and Revert

Jean's chat revert flow is intentionally a **hybrid** system:

- **Jean owns file restore** using git-backed checkpoints captured in `chat/checkpoints.rs`
- **Providers own conversation history** where supported
- Claude: resume/fork from a saved assistant UUID anchor
- Codex: app-server `thread/rollback`
- OpenCode: native `session/:id/revert`
- **UI contract**: revert is exposed on the **user message**, not the assistant reply

This means "revert" is defined as:

1. restore the workspace to the checkpoint captured **before** that user prompt ran
2. remove that prompt, its assistant reply, and all later turns from Jean's local history
3. roll back provider conversation state to the corresponding earlier point

Important implications:

- Revert availability is **per run**, not global to the session
- Old chats created before restore-point metadata existed still load normally, but old turns usually won't show a revert action
- New Tauri commands in this flow (`get_revert_targets`, `revert_to_message`) must be registered in both `lib.rs` and `http_server/dispatch.rs`

## Extension Points

### Adding New Features
Expand Down
49 changes: 49 additions & 0 deletions docs/developer/data-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,55 @@ function migrateKeybindings(
}
```

## Chat Sessions, Runs, and Restore Points

Chat persistence uses two layers:

1. **Session metadata** in `sessions/data/{session_id}/metadata.json`
2. **Per-run JSONL history** written by `src-tauri/src/chat/run_log.rs`

The metadata file stores session-level state plus a `runs` ledger. Each `RunEntry` records:

- user/assistant message IDs
- backend resume handles (`claude_session_id`, `codex_thread_id`, `opencode_session_id`)
- provider revert anchors
- checkpoints captured before/after the run
- run status and usage metadata

### Restore Point Model

Revert is built on **pre/post run checkpoints**, not by reconstructing edits from tool calls.

For every completed run Jean captures:

- `checkpoint_before` — filesystem state immediately before the user prompt executes
- `checkpoint_after` — filesystem state after the run completes

Checkpoints are stored in Jean app data via `src-tauri/src/chat/checkpoints.rs` using a git-backed snapshot store. This keeps restore independent from the user's working branch history.

### Revert Semantics

The product surface exposes revert on the **user message**. Reverting a user message means:

- restore `checkpoint_before`
- truncate local run/message history starting at that run
- restore provider conversation state to the matching earlier point

Provider handling differs by backend:

- **Claude**: persist a rewind anchor from the previous assistant UUID and materialize it on the next real send
- **Codex**: send `thread/rollback` for the number of turns being discarded
- **OpenCode**: send native `session/:id/revert` using the provider user-message anchor (`info.parentID`)

### Compatibility With Older Sessions

Older chats continue to load because revert fields are optional and defaulted during deserialization. However, runs created before restore-point support usually do **not** have:

- checkpoints
- provider revert anchors

Those older turns remain readable and sendable, but typically won't show a revert affordance.

## Best Practices

1. **Use atomic writes**: Always write to temp file then rename
Expand Down
7 changes: 6 additions & 1 deletion docs/developer/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,12 @@ if (cmd === 'create_session') {
}
```

Current dynamic handlers: `get_sessions`, `create_session`, `rename_session`, `set_active_session`, `set_session_model`, `get_session`, `send_chat_message`.
Current dynamic handlers: `get_sessions`, `create_session`, `rename_session`, `set_active_session`, `set_session_model`, `get_session`, `get_revert_targets`, `revert_to_message`, `send_chat_message`.

Recent chat additions that also need default mock coverage:

- `get_revert_targets`
- `revert_to_message`

#### Override Precedence

Expand Down
2 changes: 2 additions & 0 deletions e2e/fixtures/invoke-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export const defaultResponses: Record<string, unknown> = {
created_at: Date.now() / 1000,
messages: [],
},
get_revert_targets: [],
revert_to_message: null,

// Preferences
load_preferences: mockPreferences,
Expand Down
62 changes: 59 additions & 3 deletions e2e/fixtures/tauri-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ export const test = base.extend<TauriMockFixtures>({
return sessionStore[worktreeId]
}

function findSession(sessionId?: unknown) {
if (typeof sessionId !== 'string' || !sessionId) return null
for (const store of Object.values(sessionStore)) {
const session = store.sessions.find(s => s.id === sessionId)
if (session) return session
}
return null
}

// Commands that need dynamic responses based on args
const dynamicHandlers: Record<
string,
Expand Down Expand Up @@ -110,9 +119,15 @@ export const test = base.extend<TauriMockFixtures>({
return null
},
get_session: args => {
const wid = (args?.worktreeId as string) ?? 'unknown'
const store = getWorktreeStore(wid)
const session = store.sessions.find(s => s.id === args?.sessionId)
const session =
findSession(args?.sessionId) ??
(() => {
const wid = (args?.worktreeId as string) ?? 'unknown'
const store = getWorktreeStore(wid)
return (
store.sessions.find(s => s.id === args?.sessionId) ?? null
)
})()
return session
? structuredClone(session)
: {
Expand All @@ -123,6 +138,13 @@ export const test = base.extend<TauriMockFixtures>({
messages: [],
}
},
get_revert_targets: args => {
const session = findSession(args?.sessionId)
return structuredClone(
(session as { revert_targets?: unknown[] } | null)
?.revert_targets ?? []
)
},
send_chat_message: args => {
// Return a mock assistant ChatMessage
// Actual streaming is handled via emitEvent
Expand All @@ -140,6 +162,39 @@ export const test = base.extend<TauriMockFixtures>({
cancelled: false,
}
},
revert_to_message: args => {
const session = findSession(args?.sessionId) as {
messages?: Array<{ id?: string }>
revert_targets?: Array<{ userMessageId?: string }>
updated_at?: number
} | null
if (!session) return null

const userMessageId = args?.userMessageId as string | undefined
if (userMessageId && Array.isArray(session.messages)) {
const targetIndex = session.messages.findIndex(
message => message.id === userMessageId
)
if (targetIndex >= 0) {
session.messages = session.messages.slice(0, targetIndex)
}
}

if (userMessageId && Array.isArray(session.revert_targets)) {
const targetIndex = session.revert_targets.findIndex(
target => target.userMessageId === userMessageId
)
if (targetIndex >= 0) {
session.revert_targets = session.revert_targets.slice(
0,
targetIndex
)
}
}

session.updated_at = Date.now() / 1000
return null
},
}

const handlers: Record<string, (args?: any) => unknown> = {}
Expand All @@ -165,6 +220,7 @@ export const test = base.extend<TauriMockFixtures>({
;(window as any).__JEAN_E2E_MOCK__ = {
invokeHandlers: handlers,
eventEmitter: new EventTarget(),
sessionStore,
}
},
{ responseMap: responses, overrideKeys }
Expand Down
159 changes: 159 additions & 0 deletions e2e/tests/revert.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { test, expect, activateWorktree } from '../fixtures/tauri-mock'

async function seedSessionWithMessages(
mockPage: Parameters<typeof test>[0]['mockPage'],
sessionId: string
) {
await mockPage.evaluate(
({ sessionId }) => {
const mock = (window as any).__JEAN_E2E_MOCK__
const stores = Object.values(mock.sessionStore ?? {}) as Array<{
sessions: Array<Record<string, unknown>>
}>
const session = stores
.flatMap(store => store.sessions)
.find(candidate => candidate.id === sessionId)
if (!session) throw new Error(`Session ${sessionId} not found`)

session.updated_at = Date.now() / 1000
session.messages = [
{
id: 'user-1',
session_id: sessionId,
role: 'user',
content: 'First prompt',
timestamp: 1,
tool_calls: [],
},
{
id: 'assistant-1',
session_id: sessionId,
role: 'assistant',
content: 'First reply',
timestamp: 2,
tool_calls: [],
content_blocks: [{ type: 'text', text: 'First reply' }],
},
{
id: 'user-2',
session_id: sessionId,
role: 'user',
content: 'Second prompt',
timestamp: 3,
tool_calls: [],
},
{
id: 'assistant-2',
session_id: sessionId,
role: 'assistant',
content: 'Second reply',
timestamp: 4,
tool_calls: [],
content_blocks: [{ type: 'text', text: 'Second reply' }],
},
]
session.revert_targets = [
{ userMessageId: 'user-1', available: true, reason: 'ready' },
{ userMessageId: 'user-2', available: true, reason: 'ready' },
]
},
{ sessionId }
)
}

test.describe('Thread revert', () => {
test('reverts from a user message after confirmation', async ({
mockPage,
emitEvent,
}) => {
await activateWorktree(mockPage, 'fuzzy-tiger')

await mockPage.locator('button[aria-label="New session"]').click()
await mockPage.waitForTimeout(300)

const sessionId = await mockPage
.locator('[data-session-id]')
.first()
.getAttribute('data-session-id')
expect(sessionId).toBeTruthy()

await seedSessionWithMessages(mockPage, sessionId!)
await emitEvent('cache:invalidate', { keys: ['sessions'] })

await expect(mockPage.getByText('First prompt')).toBeVisible()
await expect(mockPage.getByText('Second prompt')).toBeVisible()

await mockPage.getByText('Second prompt').hover()
const revertButton = mockPage
.getByRole('button', { name: /revert to this message/i })
.last()
await expect(revertButton).toBeVisible()
await revertButton.click()

await expect(
mockPage.getByRole('heading', {
name: 'Revert this thread to checkpoint?',
})
).toBeVisible()
await expect(
mockPage.getByText(
'This will discard newer messages and turn diffs in this thread.'
)
).toBeVisible()
await expect(
mockPage.getByText('This action cannot be undone.')
).toBeVisible()

await mockPage.getByRole('button', { name: 'Revert thread' }).click()

await expect(mockPage.getByText('First prompt')).toBeVisible()
await expect(mockPage.getByText('First reply')).toBeVisible()
await expect(mockPage.getByText('Second prompt')).not.toBeVisible()
await expect(mockPage.getByText('Second reply')).not.toBeVisible()
})

test('does not show revert for non-revertable user messages', async ({
mockPage,
emitEvent,
}) => {
await activateWorktree(mockPage, 'fuzzy-tiger')

await mockPage.locator('button[aria-label="New session"]').click()
await mockPage.waitForTimeout(300)

const sessionId = await mockPage
.locator('[data-session-id]')
.first()
.getAttribute('data-session-id')
expect(sessionId).toBeTruthy()

await seedSessionWithMessages(mockPage, sessionId!)
await mockPage.evaluate(
({ sessionId }) => {
const mock = (window as any).__JEAN_E2E_MOCK__
const stores = Object.values(mock.sessionStore ?? {}) as Array<{
sessions: Array<Record<string, unknown>>
}>
const session = stores
.flatMap(store => store.sessions)
.find(candidate => candidate.id === sessionId)
if (!session) throw new Error(`Session ${sessionId} not found`)

session.revert_targets = [
{
userMessageId: 'user-1',
available: false,
reason: 'missing_checkpoint',
},
]
},
{ sessionId }
)
await emitEvent('cache:invalidate', { keys: ['sessions'] })

await mockPage.getByText('First prompt').hover()
await expect(
mockPage.getByRole('button', { name: /revert to this message/i })
).not.toBeVisible()
})
})
Loading