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
19 changes: 10 additions & 9 deletions apps/desktop/src/main/browser-agent/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1099,26 +1099,27 @@ describe('browser-agent session', () => {
expect(session.getTabsState().activeTabId).toBe(visible.id)
})

it('moves the next agent action to a background copy after the user claims its tab', () => {
it('keeps agent actions on a tab after the user selects it', () => {
const visible = session.ensureTab()
session.switchTab(visible.id)

const agent = session.ensureAutomationTab()

expect(agent.id).not.toBe(visible.id)
expect(agent.id).toBe(visible.id)
expect(session.getTabsState().activeTabId).toBe(visible.id)
expect(session.getTabsState().automationTabId).toBe(agent.id)
expect(session.getTabsState().automationTabId).toBe(visible.id)
expect(session.listTabs()).toHaveLength(1)
})

it('does not create a background tab until the agent resumes after a toolbar action', () => {
it('keeps agent actions on a tab after a toolbar action claims it', () => {
const visible = session.ensureTab()

expect(session.claimActiveTabForUser()?.id).toBe(visible.id)
expect(session.listTabs()).toHaveLength(1)

const agent = session.ensureAutomationTab()
expect(agent.id).not.toBe(visible.id)
expect(session.listTabs()).toHaveLength(2)
expect(agent.id).toBe(visible.id)
expect(session.listTabs()).toHaveLength(1)
})

it('does not treat a passive native focus event as user takeover', () => {
Expand All @@ -1134,7 +1135,7 @@ describe('browser-agent session', () => {
expect(session.listTabs()).toHaveLength(1)
})

it('moves automation to a background copy after real page interaction', () => {
it('keeps automation on the same tab after real page interaction', () => {
const visible = session.ensureTab()
const contents = (visible.view as unknown as MockView).webContents
const beforeMouse = contents.on.mock.calls.find(
Expand All @@ -1143,9 +1144,9 @@ describe('browser-agent session', () => {

beforeMouse?.({}, { type: 'mouseDown' })

expect(session.ensureAutomationTab().id).not.toBe(visible.id)
expect(session.ensureAutomationTab().id).toBe(visible.id)
expect(session.getTabsState().activeTabId).toBe(visible.id)
expect(session.listTabs()).toHaveLength(2)
expect(session.listTabs()).toHaveLength(1)
})

it('clears automation indicators instead of moving them when their tab closes', () => {
Expand Down
26 changes: 2 additions & 24 deletions apps/desktop/src/main/browser-agent/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1489,21 +1489,7 @@ function addTabInternal({
return tab
}

/** Forks the agent cursor before the user takes over the same live page. */
function yieldAutomationTabToUser(tab: AgentTab): void {
if (currentScope.automationTabId !== tab.id) return
const replacement = addTabInternal({ activate: false, notify: false })
currentScope.automationTabId = replacement.id
const url = sanitizeRestorableUrl(tabUrl(tab))
if (url && url !== 'about:blank') {
void replacement.view.webContents.loadURL(url).catch(() => {})
}
applyActiveTabThrottling()
persistBrowserSession()
events?.onTabsChanged()
}

/** Marks the visible page as user-owned; automation forks only if it acts again. */
/** Marks the visible page as user-selected without blocking automation on it. */
export function claimActiveTabForUser(): AgentTab | null {
const tab = activeTab()
if (!tab) return null
Expand Down Expand Up @@ -1590,10 +1576,6 @@ export function addAutomationTab(): AgentTab {
export function ensureAutomationTab(): AgentTab {
restoreBrowserSession()
let tab = automationTab()
if (tab?.id === currentScope.activeTabId && currentScope.visibleTabUserSelected) {
yieldAutomationTabToUser(tab)
tab = automationTab()
}
if (tab) return tab
tab = activeTab()
Comment thread
Sg312 marked this conversation as resolved.
if (tab) {
Expand All @@ -1608,11 +1590,7 @@ export function ensureAutomationTab(): AgentTab {
/** Current agent target without creating one. */
export function requireAutomationTab(): AgentTab {
restoreBrowserSession()
let tab = automationTab()
if (tab?.id === currentScope.activeTabId && currentScope.visibleTabUserSelected) {
yieldAutomationTabToUser(tab)
tab = automationTab()
}
const tab = automationTab()
if (!tab) {
throw new SessionError('No page is open yet — call browser_navigate or browser_open_tab first.')
}
Expand Down
21 changes: 1 addition & 20 deletions apps/desktop/src/main/terminal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,33 +1133,14 @@ export class TerminalService {
private requireSession(args: TerminalToolArgs): TerminalSession {
const requested = typeof args.terminalId === 'string' ? args.terminalId : null
if (requested) {
if (requested === this.activeId && this.activeTerminalUserSelected) {
const claimed = this.sessions.get(requested)
if (claimed?.alive && requested === this.agentActiveId) {
return this.spawn(claimed.currentCwd ?? this.startingCwd(), claimed.cols, claimed.rows, {
activateVisible: false,
activateAgent: true,
})
}
throw new TerminalError(
'INVALID_REQUEST',
'That terminal is currently being used by the user. Open or switch to another agent terminal first.'
)
}
const session = this.sessions.get(requested)
if (!session?.alive) {
throw new TerminalError('NO_SUCH_TERMINAL', unknownTerminal(requested))
}
return session
}

let active = this.agentActiveId ? this.sessions.get(this.agentActiveId) : null
if (active?.terminalId === this.activeId && this.activeTerminalUserSelected) {
active = this.spawn(active.currentCwd ?? this.startingCwd(), active.cols, active.rows, {
activateVisible: false,
activateAgent: true,
})
}
const active = this.agentActiveId ? this.sessions.get(this.agentActiveId) : null
if (active?.alive) return active

const spawned = this.spawn(this.startingCwd(), 80, 24, {
Expand Down
17 changes: 9 additions & 8 deletions apps/desktop/src/main/terminal/registry.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { statSync } from 'node:fs'
import type {
TerminalCommandEvent,
TerminalOperation,
TerminalStartOptions,
TerminalTabsState,
TerminalToolArgs,
TerminalToolResponse,
import {
describeRunningCommand,
type TerminalCommandEvent,
type TerminalOperation,
type TerminalStartOptions,
type TerminalTabsState,
type TerminalToolArgs,
type TerminalToolResponse,
} from '@sim/terminal-protocol'
import { type BrowserWindow, dialog, type WebContents } from 'electron'
import type { TerminalSessionSnapshot } from '@/main/desktop-chat-session-store'
Expand Down Expand Up @@ -239,7 +240,7 @@ export class TerminalRegistry {
dialog.showMessageBoxSync(ownerWindow, {
type: 'warning',
title: 'Close Running Terminal?',
message: `${running} is still running.`,
message: `${describeRunningCommand(running)} is still running.`,
detail: 'Closing this terminal will stop the process.',
buttons: ['Close Terminal', 'Cancel'],
defaultId: 1,
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/main/terminal/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ describe('focus-gated shortcuts', () => {
expect(runShortcut(terminal, 'new-tab', renderer.window)).toBe(false)
})

it('moves agent work to a background shell after the user claims the visible terminal', async () => {
it('keeps agent work in the visible terminal after the user focuses it', async () => {
const terminal = service()
const started = terminal.start({ cols: 80, rows: 24 })
const visibleId = started.activeTerminalId as string
Expand All @@ -243,9 +243,10 @@ describe('focus-gated shortcuts', () => {
const result = response.result as { terminalId: string } | undefined

expect(response.ok).toBe(true)
expect(result?.terminalId).not.toBe(visibleId)
expect(result?.terminalId).toBe(visibleId)
expect(terminal.getTabs().tabs).toHaveLength(1)
expect(terminal.getTabs().activeTerminalId).toBe(visibleId)
expect(terminal.getTabs().agentActiveTerminalId).toBe(result?.terminalId)
expect(terminal.getTabs().agentActiveTerminalId).toBe(visibleId)
})

it('keeps a running terminal open when close confirmation is declined', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ import { WebglAddon } from '@xterm/addon-webgl'
import { type IBufferRange, Terminal } from '@xterm/xterm'
import { useTheme } from 'next-themes'
import '@xterm/xterm/css/xterm.css'
import type { TerminalTabState, TerminalTabsState } from '@sim/terminal-protocol'
import {
describeRunningCommand,
type TerminalTabState,
type TerminalTabsState,
} from '@sim/terminal-protocol'
import { SIM_RESOURCE_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { TERMINAL_SESSION_RESOURCE_ID } from '@/lib/copilot/resources/types'
import { getDesktopBridge } from '@/lib/desktop'
Expand Down Expand Up @@ -686,7 +690,9 @@ const TerminalView = memo(function TerminalView({
const closeThisTerminal = useCallback(() => {
if (
running &&
!window.confirm(`${running} is still running. Close this terminal and stop it?`)
!window.confirm(
`${describeRunningCommand(running)} is still running. Close this terminal and stop it?`
)
) {
return
}
Expand Down Expand Up @@ -978,7 +984,9 @@ export function TerminalSession({ visible, scopeId }: TerminalSessionProps) {
const tab = tabs.find((entry) => entry.terminalId === terminalId)
if (
tab?.running &&
!window.confirm(`${tab.running} is still running. Close this terminal and stop it?`)
!window.confirm(
`${describeRunningCommand(tab.running)} is still running. Close this terminal and stop it?`
)
) {
return
}
Expand Down
9 changes: 4 additions & 5 deletions apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,13 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
activeResourceParamRef.current = activeResourceParam

function handleResourceEvent(resourceId: string) {
// Agent work should always make the resource surface available. Expanding
// the panel is independent from selecting a resource: once the user has
// chosen another resource, the agent may work in the background without
// taking that selection away.
// Agent work should always make the resource surface available, but it
// must never replace an existing selection. Activity in another resource
// stays in the background and gets an attention marker instead.
if (isResourceCollapsedRef.current) setIsResourceCollapsed(false)

const activeResourceId = activeResourceParamRef.current
if (userOwnsResourceViewRef.current && activeResourceId && activeResourceId !== resourceId) {
if (activeResourceId && activeResourceId !== resourceId) {
setResourceActivityIds((current) => new Set(current).add(resourceId))
return
}
Expand Down
8 changes: 6 additions & 2 deletions packages/terminal-protocol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,19 @@
},
"scripts": {
"type-check": "tsc --noEmit",
"test": "vitest run",
"lint": "biome check --write --unsafe .",
"lint:check": "biome check .",
"format": "biome format --write .",
"format:check": "biome format ."
},
"dependencies": {},
"dependencies": {
"@sim/utils": "workspace:*"
},
"devDependencies": {
"@sim/tsconfig": "workspace:*",
"@types/node": "24.2.1",
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"vitest": "^4.1.0"
}
}
51 changes: 51 additions & 0 deletions packages/terminal-protocol/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describeRunningCommand } from '@sim/terminal-protocol'
import { describe, expect, it } from 'vitest'

describe('describeRunningCommand', () => {
it('names the program of a bare command', () => {
expect(describeRunningCommand('bun run dev')).toBe('bun')
})

it('takes the basename of an absolute program path', () => {
expect(describeRunningCommand('/opt/homebrew/bin/claude --resume')).toBe('claude')
})

it('drops the cd/export preamble an agent-launched terminal carries', () => {
const command =
'cd /Users/someone/Desktop/sim && export PATH="/opt/homebrew/bin:$PATH" && claude "Fix this runtime error in this repo: file upload fails"'
expect(describeRunningCommand(command)).toBe('claude')
})

it('ignores separators inside quotes', () => {
expect(describeRunningCommand('claude "build && test; deploy | ship"')).toBe('claude')
expect(describeRunningCommand("claude 'a && b'")).toBe('claude')
})

it('honors backslash escapes only where the shell does', () => {
// Escaped quote keeps the double-quoted run open, so `&&` stays quoted.
expect(describeRunningCommand('claude "say \\" && rm -rf /"')).toBe('claude')
// A backslash is literal inside single quotes, so that run closes and the
// following `&&` is a real separator.
expect(describeRunningCommand("claude 'a\\' && jest")).toBe('jest')
})

it('skips environment assignments and wrapper words', () => {
expect(describeRunningCommand('NODE_ENV=test bun test')).toBe('bun')
expect(describeRunningCommand('sudo /usr/bin/docker compose up')).toBe('docker')
expect(describeRunningCommand('env FOO=1 nohup python train.py')).toBe('python')
})

it('reads the last stage of a pipeline', () => {
expect(describeRunningCommand('cat log.txt | grep error | less')).toBe('less')
})

it('bounds a label it cannot reduce to a program name', () => {
const label = describeRunningCommand(`--${'x'.repeat(200)}`)
expect(label.length).toBeLessThanOrEqual(33)
expect(label.endsWith('…')).toBe(true)
})

it('describes an unannounced command rather than returning nothing', () => {
expect(describeRunningCommand(' ')).toBe('a command')
})
})
Loading
Loading