Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ dist/
dist-runtime/
.DS_Store
*.tsbuildinfo
__pycache__/
*.py[cod]
.worktrees/

# Build artifacts (keep signing entitlements tracked)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ brew install --cask cate

- **Integrated agent chats.** T3 Code runs Codex, Claude Code, Cursor, Grok, OpenCode and Antigravity in a full chat panel with streaming output, tool calls and approvals.
- **Agent-aware terminals.** Supported agent CLIs report turn start, turn end, and permission prompts, so each panel shows running / waiting / finished and pings you when it needs an answer.
- **Agent sessions survive restarts.** Reopen the project and terminals come back with their scrollback, each agent reattached with its own resume command.
- **Agent sessions survive restarts.** Reopen the project and terminals come back with their scrollback, each agent reattached with its own resume command. Hermes users can enable exact profile-aware restore with the [Hermes integration](integrations/hermes/README.md).
- **Worktrees for parallel branches.** Type what you're working on and Cate creates the worktree and branch, off a local branch, a remote branch, or an open PR.
- **Panels on a canvas or in a dock.** Terminals, Monaco editors, browsers, PDF/image/DOCX viewers, nested canvases. Float, dock into tabs and splits, or detach into their own window. Layout persists per project.
- **Git and search.** Multi-repo source control, git badges in the file tree, side-by-side diffs, ripgrep search, and `Cmd+K` for commands, panels, and files.
Expand Down
38 changes: 31 additions & 7 deletions e2e/agent-changes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,22 @@ let directory: string
let workspaceId: string
test.beforeEach(async () => {
directory = realpathSync(mkdtempSync(path.join(tmpdir(), 'cate-agent-capture-e2e-')))
;({ electronApp: app, mainWindow: page } = await launchApp({ userDataDir: path.join(directory, 'userdata') }))
// Avoid launchApp's disposable seed project: re-rooting that workspace leaves
// two on-disk snapshots with the same id, and the empty one can win restore.
;({ electronApp: app, mainWindow: page } = await launchApp({
userDataDir: path.join(directory, 'userdata'),
empty: true,
}))
await openTrustedWorkspace(page, directory)
await page.evaluate(() => window.__cateE2E!.createPanel('canvas'))
await page.waitForSelector('[data-canvas-panel-id]')
workspaceId = await page.evaluate(() => window.__cateE2E!.selectedWorkspaceId())
await resetViewport(page)
})
test.afterEach(async () => { if (app) await closeApp(app); rmSync(directory, { recursive: true, force: true }) })
test.afterEach(async () => {
if (app) await closeApp(app)
rmSync(directory, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 })
})

for (const agentId of ['claude-code', 'codex', 'cursor', 'grok', 'kiro', 'opencode']) {
test(`${agentId}: real terminal hooks reach the filtered diff panel without other sessions or Git changes`, async () => {
Expand Down Expand Up @@ -67,13 +77,27 @@ for (const agentId of ['claude-code', 'codex', 'cursor', 'grok', 'kiro', 'openco
// The workspace, review panel, source association and recorded history
// must all survive a full app/runtime restart, not only a fresh launch.
await closeApp(app)
;({ electronApp: app, mainWindow: page } = await launchApp({ userDataDir: path.join(directory, 'userdata') }))
;({ electronApp: app, mainWindow: page } = await launchApp({
userDataDir: path.join(directory, 'userdata'),
empty: true,
}))
await page.waitForFunction(
() => performance.getEntriesByName('session-restored').length > 0,
undefined,
{ timeout: 30_000 },
)
await expect(page.getByText('This review panel hit an error')).toHaveCount(0)
await page.waitForFunction(
(id) => !!window.__cateE2E!.terminalPtyId(id),
node,
{ timeout: 30_000 },
)
await resetViewport(page)
const restoredSource = await titleBarCentre(page, node)
expect(restoredSource).not.toBeNull()
await page.mouse.click(restoredSource!.x, restoredSource!.y + 60)
await page.locator(`[data-node-id="${node}"]`).getByRole('button', { name: 'Open agent changes' }).click()
const restoredNode = page.locator(`[data-node-id="${node}"]`)
await expect(restoredNode).toBeVisible()
const unfocusedOverlay = restoredNode.locator('[data-unfocused-overlay="true"]')
if (await unfocusedOverlay.isVisible()) await unfocusedOverlay.click()
await restoredNode.getByRole('button', { name: 'Open agent changes' }).click()
await page.getByRole('button', { name: /Use Agent changes/ }).click()
await expect(page.locator('[data-review-file="own.ts"]')).toBeVisible()
await expect(page.locator('[data-review-file="other.ts"]')).toHaveCount(0)
Expand Down
91 changes: 91 additions & 0 deletions e2e/fixtures/fake-hermes-session.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use strict'
/* global process, require, URL, Buffer, setInterval */
/* eslint-disable @typescript-eslint/no-require-imports */

const fs = require('node:fs')
const http = require('node:http')

const args = process.argv.slice(2)
const flag = (name) => {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
}

const endpoint = process.env.CATE_HOOK_ENDPOINT
const token = process.env.CATE_HOOK_TOKEN
const terminalId = process.env.CATE_TERMINAL_ID
const processStartedAt = process.hrtime.bigint().toString()
const logPath = process.env.FAKE_HERMES_LOG
const profile = flag('--profile') || 'default'
const resumedSessionId = flag('--resume')
const sessionId = resumedSessionId || `fake_${String(terminalId).replace(/[^A-Za-z0-9_-]/g, '_')}`

function post(hookEventName) {
if (!endpoint || !token || !terminalId) return Promise.resolve()
const body = JSON.stringify({
agentId: 'hermes',
terminalId,
pid: process.pid,
processStartedAt,
payload: {
hook_event_name: hookEventName,
session_id: sessionId,
cwd: process.cwd(),
profile,
platform: 'cli',
},
})
return new Promise((resolve) => {
const url = new URL(endpoint)
const request = http.request({
hostname: url.hostname,
port: url.port,
path: url.pathname || '/',
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
'content-length': Buffer.byteLength(body),
},
timeout: 1_000,
}, (response) => {
response.resume()
response.once('end', resolve)
})
request.once('timeout', () => { request.destroy(); resolve() })
request.once('error', resolve)
request.end(body)
})
}

let finishing = false
async function finish() {
if (finishing) return
finishing = true
await post('on_session_finalize')
process.exit(0)
}

void (async () => {
if (logPath) {
fs.appendFileSync(logPath, `${JSON.stringify({
phase: resumedSessionId ? 'resume' : 'start',
pid: process.pid,
terminalId,
sessionId,
profile,
args,
})}\n`)
}
await post('on_session_start')
await post('pre_llm_call')
process.stdout.write(`FAKE_HERMES_${resumedSessionId ? 'RESUMED' : 'READY'} ${sessionId}\r\n`)
process.on('SIGTERM', () => { void finish() })
process.on('SIGINT', () => { void finish() })
process.stdin.setEncoding('utf8')
process.stdin.on('data', (chunk) => {
if (chunk.includes('__CATE_E2E_EXIT__')) void finish()
})
process.stdin.resume()
setInterval(() => {}, 60_000).unref()
})()
Loading