Skip to content

Commit fb28fff

Browse files
committed
feat(copilot): attach browser and terminal tab context
1 parent 4ba5456 commit fb28fff

11 files changed

Lines changed: 224 additions & 20 deletions

File tree

apps/desktop/src/main/terminal/session.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,11 @@ export class TerminalSession {
445445
// data the agent reads — it must stay true the instant a command starts.
446446
title: directory || 'Terminal',
447447
cwd: this.cwd,
448-
running: this.foregroundCommand,
448+
// Never an empty string. A shell can start a command without announcing
449+
// its text, and "" would read as "nothing is running" to everything
450+
// downstream while the terminal is in fact busy — the agent would treat
451+
// it as free and the tab would render a blank label.
452+
running: this.foregroundCommand === null ? null : this.foregroundCommand.trim() || 'command',
449453
interactive: this.altScreen,
450454
active,
451455
}

apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import type { ReactNode } from 'react'
22
import {
33
Calendar,
4+
Cursor,
45
Database,
56
Folder as FolderIcon,
67
Library,
78
Table as TableIcon,
89
Task,
10+
TerminalWindow,
911
Workflow,
1012
} from '@sim/emcn/icons'
1113
import { AgentSkillsIcon, McpIcon } from '@/components/icons'
@@ -53,6 +55,14 @@ function renderIntegrationTile({ context, className }: RenderIconArgs): ReactNod
5355
* without an icon.
5456
*/
5557
export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKindConfig> = {
58+
browser_tab: {
59+
label: 'Browser tab',
60+
renderIcon: ({ className }) => <Cursor className={className} />,
61+
},
62+
terminal_tab: {
63+
label: 'Terminal',
64+
renderIcon: ({ className }) => <TerminalWindow className={className} />,
65+
},
5666
workflow: { label: 'Workflow', renderIcon: renderWorkflowIcon },
5767
current_workflow: { label: 'Current workflow', renderIcon: renderWorkflowIcon },
5868
workflow_block: { label: 'Block', renderIcon: renderWorkflowIcon },

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
'use client'
22

3-
import { type MouseEvent as ReactMouseEvent, useCallback, useMemo, useState } from 'react'
3+
import {
4+
type DragEvent as ReactDragEvent,
5+
type MouseEvent as ReactMouseEvent,
6+
useCallback,
7+
useMemo,
8+
useState,
9+
} from 'react'
410
import { type BrowserTabState, MAX_BROWSER_TABS } from '@sim/browser-protocol'
511
import { TabStrip, type TabStripItem } from '@sim/emcn'
612
import { Link, Loader } from '@sim/emcn/icons'
13+
import { SIM_RESOURCE_DRAG_TYPE } from '@/lib/copilot/resource-types'
714
import { faviconUrl } from '@/lib/core/utils/favicon'
815
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
916
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -98,6 +105,22 @@ export function BrowserTabStrip({
98105
[tabs, activeTabId]
99106
)
100107

108+
// Dragging a tab into the chat attaches it as context. `copyMove` because
109+
// the strip already set `move` for its own reordering, and a drop target
110+
// asking for `copy` is refused outright unless copying is allowed too.
111+
const startTabDrag = useCallback(
112+
(event: ReactDragEvent<HTMLDivElement>, tabId: string) => {
113+
const tab = tabs.find((entry) => entry.tabId === tabId)
114+
if (!tab) return
115+
event.dataTransfer.effectAllowed = 'copyMove'
116+
event.dataTransfer.setData(
117+
SIM_RESOURCE_DRAG_TYPE,
118+
JSON.stringify({ type: 'browser', id: tab.tabId, title: tabTitle(tab) })
119+
)
120+
},
121+
[tabs]
122+
)
123+
101124
const openTabContextMenu = useCallback(
102125
(event: ReactMouseEvent<HTMLDivElement>, tabId: string) => {
103126
window.getSelection()?.removeAllRanges()
@@ -114,6 +137,7 @@ export function BrowserTabStrip({
114137
onClose={onCloseTab}
115138
onNew={onNewTab}
116139
onTabContextMenu={openTabContextMenu}
140+
onTabDragStart={startTabDrag}
117141
maxTabs={MAX_BROWSER_TABS}
118142
{...(reorderingSupported ? { onReorder: onReorderTab } : {})}
119143
>

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import {
4+
type DragEvent as ReactDragEvent,
45
type MouseEvent as ReactMouseEvent,
56
useCallback,
67
useEffect,
@@ -19,6 +20,7 @@ import { Terminal } from '@xterm/xterm'
1920
import { useTheme } from 'next-themes'
2021
import '@xterm/xterm/css/xterm.css'
2122
import { MAX_TERMINALS, type TerminalTabState } from '@sim/terminal-protocol'
23+
import { SIM_RESOURCE_DRAG_TYPE } from '@/lib/copilot/resource-types'
2224
import { TERMINAL_SESSION_RESOURCE_ID } from '@/lib/copilot/resources/types'
2325
import {
2426
closeTerminal,
@@ -50,10 +52,27 @@ const logger = createLogger('TerminalSession')
5052
*/
5153
const COMMAND_SETTLE_MS = 1_000
5254

55+
/** Full working directory, plus whatever the shell is running in it. */
56+
function terminalTooltip(tab: TerminalTabState): string {
57+
const where = tab.cwd ?? 'Terminal'
58+
return tab.running ? `${where}${tab.running}` : where
59+
}
60+
5361
function sameIds(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
5462
return a.size === b.size && [...a].every((id) => b.has(id))
5563
}
5664

65+
/**
66+
* Whether a tab should be named after what it is running rather than where it
67+
* is. A full-screen program is named the moment it appears: the delay exists
68+
* to stop `ls` flickering the label, and an editor or coding agent is not a
69+
* transient command — it holds the terminal until it is quit, so there is
70+
* nothing to wait out.
71+
*/
72+
function namesItsCommand(tab: TerminalTabState, settled: ReadonlySet<string>): boolean {
73+
return Boolean(tab.running) && (tab.interactive || settled.has(tab.terminalId))
74+
}
75+
5776
/**
5877
* The terminals whose command has been running long enough to show. Returns a
5978
* stable set, so a tab strip that would render identically does not re-render.
@@ -513,12 +532,14 @@ export function TerminalSession() {
513532
const items = useMemo<TabStripItem[]>(
514533
() =>
515534
tabs.map((tab) => {
516-
// A command only reaches the tab once it has run long enough to be
517-
// worth naming; until then the tab stays as its directory.
518-
const naming = settledCommands.has(tab.terminalId) ? tab.running : null
535+
const naming = namesItsCommand(tab, settledCommands) ? tab.running : null
519536
return {
520537
id: tab.terminalId,
521538
title: naming ?? tab.title,
539+
// The label is a basename, and the tab may be running something it
540+
// is not naming yet, so hovering gives the whole picture: where the
541+
// shell is, and what it is doing there.
542+
tooltip: terminalTooltip(tab),
522543
icon:
523544
naming && !tab.interactive ? (
524545
<Loader className='size-[12px] shrink-0 animate-spin text-[var(--text-icon)]' />
@@ -559,6 +580,22 @@ export function TerminalSession() {
559580
void openTerminal(cwd ?? undefined)
560581
}, [])
561582

583+
// Dragging a terminal tab into the chat attaches it as context. This strip
584+
// has no reordering, so supplying this is also what makes its tabs
585+
// draggable at all.
586+
const startTabDrag = useCallback(
587+
(event: ReactDragEvent<HTMLDivElement>, terminalId: string) => {
588+
const tab = tabs.find((entry) => entry.terminalId === terminalId)
589+
if (!tab) return
590+
event.dataTransfer.effectAllowed = 'copy'
591+
event.dataTransfer.setData(
592+
SIM_RESOURCE_DRAG_TYPE,
593+
JSON.stringify({ type: 'terminal', id: tab.terminalId, title: tab.title })
594+
)
595+
},
596+
[tabs]
597+
)
598+
562599
const openTabContextMenu = useCallback(
563600
(event: ReactMouseEvent<HTMLDivElement>, terminalId: string) => {
564601
window.getSelection()?.removeAllRanges()
@@ -576,6 +613,7 @@ export function TerminalSession() {
576613
onSelect={handleSwitch}
577614
onNew={handleNew}
578615
onTabContextMenu={openTabContextMenu}
616+
onTabDragStart={startTabDrag}
579617
maxTabs={MAX_TERMINALS}
580618
newTabLabel='New terminal'
581619
onClose={handleClose}

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,16 @@ export const SPEECH_RECOGNITION_LANG = 'en-US'
9999
* so adding a new resource type fails compilation here until a conversion is
100100
* supplied — preventing silent drift between the two taxonomies.
101101
*/
102-
// `browser` and `terminal` are live desktop panels, not addressable workspace
103-
// entities, so neither can become chat context.
102+
// A dragged `browser`/`terminal` resource is one TAB, and its `id` is that
103+
// tab's id — the panel itself is a singleton with nothing to point at. Both
104+
// become pointers the agent resolves with its own tools rather than content
105+
// captured here, so what it reads is the tab as it stands when it looks.
104106
const RESOURCE_TO_CONTEXT: Record<
105-
Exclude<MothershipResourceType, 'browser' | 'terminal'>,
107+
MothershipResourceType,
106108
(resource: MothershipResource) => ChatContext
107109
> = {
110+
browser: (r) => ({ kind: 'browser_tab', tabId: r.id, label: r.title }),
111+
terminal: (r) => ({ kind: 'terminal_tab', terminalId: r.id, label: r.title }),
108112
workflow: (r) => ({ kind: 'workflow', workflowId: r.id, label: r.title }),
109113
knowledgebase: (r) => ({ kind: 'knowledge', knowledgeId: r.id, label: r.title }),
110114
table: (r) => ({ kind: 'table', tableId: r.id, label: r.title }),
@@ -119,8 +123,5 @@ const RESOURCE_TO_CONTEXT: Record<
119123
}
120124

121125
export function mapResourceToContext(resource: MothershipResource): ChatContext {
122-
if (resource.type === 'browser' || resource.type === 'terminal') {
123-
throw new Error(`Live ${resource.type} sessions cannot be attached as chat context`)
124-
}
125126
return RESOURCE_TO_CONTEXT[resource.type](resource)
126127
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { mapResourceToContext } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
6+
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
7+
8+
function resource(partial: Partial<MothershipResource> & Pick<MothershipResource, 'type'>) {
9+
return { id: 'id-1', title: 'Something', ...partial } as MothershipResource
10+
}
11+
12+
describe('mapResourceToContext', () => {
13+
it('turns a dragged browser tab into a pointer at that tab', () => {
14+
// The id is the TAB's, not the panel's: the panel is a singleton and
15+
// pointing at it would not say which page the user meant.
16+
const context = mapResourceToContext(
17+
resource({ type: 'browser', id: 'tab-7', title: 'Pull requests' })
18+
)
19+
20+
expect(context).toEqual({ kind: 'browser_tab', tabId: 'tab-7', label: 'Pull requests' })
21+
})
22+
23+
it('turns a dragged terminal tab into a pointer at that shell', () => {
24+
const context = mapResourceToContext(resource({ type: 'terminal', id: '3', title: 'sim' }))
25+
26+
expect(context).toEqual({ kind: 'terminal_tab', terminalId: '3', label: 'sim' })
27+
})
28+
29+
it('still maps the ordinary workspace resources', () => {
30+
expect(
31+
mapResourceToContext(resource({ type: 'workflow', id: 'wf-1', title: 'Deploy' }))
32+
).toEqual({ kind: 'workflow', workflowId: 'wf-1', label: 'Deploy' })
33+
expect(mapResourceToContext(resource({ type: 'table', id: 't-1', title: 'Leads' }))).toEqual({
34+
kind: 'table',
35+
tableId: 't-1',
36+
label: 'Leads',
37+
})
38+
})
39+
})

apps/sim/lib/copilot/chat/post.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ const ChatContextSchema = z.object({
152152
'integration',
153153
'skill',
154154
'mcp',
155+
'browser_tab',
156+
'terminal_tab',
155157
]),
156158
label: z.string(),
157159
chatId: z.string().optional(),
@@ -167,6 +169,8 @@ const ChatContextSchema = z.object({
167169
skillId: z.string().optional(),
168170
serverId: z.string().optional(),
169171
scheduleId: z.string().optional(),
172+
tabId: z.string().optional(),
173+
terminalId: z.string().optional(),
170174
})
171175

172176
const ChatMessageSchema = z.object({

apps/sim/lib/copilot/chat/process-contents.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,24 @@ export async function processContextsServer(
143143
currentWorkspaceId
144144
)
145145
}
146+
// Tabs resolve to a pointer, not their contents. The agent has tools
147+
// that read a live tab, and by the time it acts the page may have
148+
// navigated or the shell scrolled on — so naming the tab it should look
149+
// at beats pasting a snapshot that was true when the message was sent.
150+
if (ctx.kind === 'browser_tab' && ctx.tabId) {
151+
return {
152+
type: 'browser_tab',
153+
tag: ctx.label ? `@${ctx.label}` : '@',
154+
content: `The user pointed at an open browser tab: "${ctx.label}" (tabId ${ctx.tabId}). Act on THIS tab — switch to it with browser_switch_tab and read it with browser_snapshot rather than assuming which tab they meant.`,
155+
}
156+
}
157+
if (ctx.kind === 'terminal_tab' && ctx.terminalId) {
158+
return {
159+
type: 'terminal_tab',
160+
tag: ctx.label ? `@${ctx.label}` : '@',
161+
content: `The user pointed at an open terminal: "${ctx.label}" (terminalId ${ctx.terminalId}). Act on THIS terminal — pass that terminalId to the terminal tool, and read its screen before assuming what is in it.`,
162+
}
163+
}
146164
if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) {
147165
return await processWorkflowBlockFromDb(
148166
ctx.workflowId,

apps/sim/stores/panel/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ export type ChatContext =
2929
| { kind: 'filefolder'; fileFolderId: string; label: string }
3030
| { kind: 'scheduledtask'; scheduleId: string; label: string }
3131
| { kind: 'docs'; label: string }
32+
/**
33+
* A tab in the desktop browser or terminal panel, dragged into the input to
34+
* say "this one". A pointer rather than a snapshot: the agent reads the tab
35+
* with its own tools, so what it sees is the live state at the moment it
36+
* looks rather than whatever was on screen when the message was sent.
37+
*/
38+
| { kind: 'browser_tab'; tabId: string; label: string }
39+
| { kind: 'terminal_tab'; terminalId: string; label: string }
3240
| { kind: 'slash_command'; command: string; label: string }
3341
| { kind: 'integration'; blockType: string; label: string }
3442
| { kind: 'skill'; skillId: string; label: string }

packages/emcn/src/components/tab-strip/tab-strip.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,38 @@ describe('tabDropIndex', () => {
4343
expect(tabDropIndex(plain, 'b', 1)).toBeNull()
4444
})
4545
})
46+
47+
describe('tab tooltips', () => {
48+
/** Mirrors the render condition: tooltip text, and whether it is shown. */
49+
function tooltipFor(tab: TabStripItem, titleTruncated: boolean) {
50+
const shown = Boolean(tab.tooltip || tab.pinned || titleTruncated)
51+
return shown ? (tab.tooltip ?? tab.title) : null
52+
}
53+
54+
it('prefers the fuller detail a tab supplies over its label', () => {
55+
// A terminal labelled with a basename can say where it actually is.
56+
const tab: TabStripItem = { id: '1', title: 'sim', tooltip: '/Users/me/code/sim — bun test' }
57+
58+
expect(tooltipFor(tab, false)).toBe('/Users/me/code/sim — bun test')
59+
})
60+
61+
it('shows that detail even when the label fits', () => {
62+
// It says something the tab cannot, so there is always a reason to hover.
63+
const tab: TabStripItem = { id: '1', title: 'sim', tooltip: '/Users/me/code/sim' }
64+
65+
expect(tooltipFor(tab, false)).not.toBeNull()
66+
})
67+
68+
it('falls back to the title, and only when the title is clipped', () => {
69+
const tab: TabStripItem = { id: '1', title: 'a very long tab title' }
70+
71+
expect(tooltipFor(tab, true)).toBe('a very long tab title')
72+
expect(tooltipFor(tab, false)).toBeNull()
73+
})
74+
75+
it('still explains a pinned tab, which renders with no label at all', () => {
76+
const tab: TabStripItem = { id: '1', title: 'GitHub', pinned: true }
77+
78+
expect(tooltipFor(tab, false)).toBe('GitHub')
79+
})
80+
})

0 commit comments

Comments
 (0)