Skip to content

Commit 84081de

Browse files
committed
fix(chat): keep shared resource tab open + round-trip selection chip cut/copy
Cursor: - handleInitialContextRemove closed a file/table's slideover tab whenever any one of its chips was removed, even when other selection chips still referenced it. onContextRemove now also passes the remaining contexts; the tab closes only once no remaining chip resolves to that resource. - file_selection/table_selection chips can't fit a portable sim:kind/id link, so cutting one from the chat input left a bare @Label with no backing data. The copy/cut path now detects a sole selection chip and rides its full context on the text/x-sim-selection MIME so paste restores it; mixed selections keep the portable/plain path.
1 parent 0d2592e commit 84081de

5 files changed

Lines changed: 72 additions & 12 deletions

File tree

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,13 @@ interface ChatSurfaceContextValue {
2626
userId?: string
2727
/** Notifies the surface owner that a context chip was added to the input. */
2828
onContextAdd: (context: ChatContext) => void
29-
/** Notifies the surface owner that a context chip was removed from the input. */
30-
onContextRemove: (context: ChatContext) => void
29+
/**
30+
* Notifies the surface owner that a context chip was removed from the input.
31+
* `remaining` is the input's context list AFTER the removal, so the owner can
32+
* tell whether any other chip still references the removed chip's resource
33+
* before closing a shared slideover tab.
34+
*/
35+
onContextRemove: (context: ChatContext, remaining: ChatContext[]) => void
3136
/** Opens a workspace resource referenced from rendered message content. */
3237
onWorkspaceResourceSelect: (resource: MothershipResource) => void
3338
}
@@ -42,7 +47,7 @@ interface ChatSurfaceProviderProps {
4247
chatId?: string
4348
userId?: string
4449
onContextAdd?: (context: ChatContext) => void
45-
onContextRemove?: (context: ChatContext) => void
50+
onContextRemove?: (context: ChatContext, remaining: ChatContext[]) => void
4651
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
4752
children: ReactNode
4853
}
@@ -74,8 +79,8 @@ export function ChatSurfaceProvider({
7479
const stableOnContextAdd = useCallback((context: ChatContext) => {
7580
onContextAddRef.current?.(context)
7681
}, [])
77-
const stableOnContextRemove = useCallback((context: ChatContext) => {
78-
onContextRemoveRef.current?.(context)
82+
const stableOnContextRemove = useCallback((context: ChatContext, remaining: ChatContext[]) => {
83+
onContextRemoveRef.current?.(context, remaining)
7984
}, [])
8085
const stableOnWorkspaceResourceSelect = useCallback((resource: MothershipResource) => {
8186
onWorkspaceResourceSelectRef.current?.(resource)

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,31 @@ export function serializeSelectionForClipboard(
160160
return result
161161
}
162162

163+
/**
164+
* Finds the selection-scoped chips (`file_selection` / `table_selection`) whose
165+
* highlighted token falls inside `selectedText`. These kinds carry an inline
166+
* text blob / row-id array that can't fit a portable `sim:kind/id` link, so the
167+
* chat input's copy/cut path round-trips them through the custom
168+
* `text/x-sim-selection` clipboard MIME instead. Uses the overlay's exact
169+
* tokenization so a label that is a substring of another never false-matches.
170+
*/
171+
export function selectionContextsInText(
172+
selectedText: string,
173+
contexts: ChatContext[]
174+
): ChatContext[] {
175+
const ranges = computeMentionHighlightRanges(selectedText, extractContextTokens(contexts))
176+
if (ranges.length === 0) return []
177+
const found: ChatContext[] = []
178+
for (const range of ranges) {
179+
const label = stripMentionTrigger(range.token)
180+
const matched = contexts.find((c) => c.label === label)
181+
if (matched && (matched.kind === 'file_selection' || matched.kind === 'table_selection')) {
182+
found.push(matched)
183+
}
184+
}
185+
return found
186+
}
187+
163188
/**
164189
* Parses all portable chip markdown links from a string, in source order.
165190
*

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2-
import { readSelectionContextFromClipboard } from '@/lib/copilot/chat/selection-clipboard'
2+
import {
3+
attachSelectionContextToClipboard,
4+
readSelectionContextFromClipboard,
5+
} from '@/lib/copilot/chat/selection-clipboard'
36
import { snapSelectionToChips } from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection'
47
import {
58
chipDisplayToken,
69
chipLinkToContext,
710
parseChipLinks,
11+
selectionContextsInText,
812
serializeSelectionForClipboard,
913
} from '@/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec'
1014
import {
@@ -1033,6 +1037,15 @@ export function usePromptEditor({
10331037
* text and round-trip by name. Returns true when it took over the clipboard
10341038
* (the caller must then perform the cut deletion itself, since the default
10351039
* was prevented).
1040+
*
1041+
* Selection chips (`file_selection` / `table_selection`) can't fit a portable
1042+
* link — their inline text / row-id payload lives only in the context. When
1043+
* the selection is exactly one such chip (the common copy/cut of a
1044+
* highlight-to-chat chip), ride its full context on the custom
1045+
* `text/x-sim-selection` MIME so paste restores it; otherwise it would leave a
1046+
* bare `@label` with no backing data. Mixed selections keep the portable/plain
1047+
* path (the single-slot MIME can't carry more than one), so the chip degrades
1048+
* to its label text there rather than dropping the rest of the selection.
10361049
*/
10371050
const writeSanitizedClipboard = useCallback(
10381051
(e: React.ClipboardEvent<HTMLTextAreaElement>): boolean => {
@@ -1041,10 +1054,18 @@ export function usePromptEditor({
10411054
const end = textarea.selectionEnd ?? 0
10421055
const selected = textarea.value.slice(start, end)
10431056
if (!selected) return false
1044-
const serialized = serializeSelectionForClipboard(
1045-
selected,
1046-
contextManagementRef.current.selectedContexts
1047-
)
1057+
const contexts = contextManagementRef.current.selectedContexts
1058+
const selectionChips = selectionContextsInText(selected, contexts)
1059+
const soleSelectionChip =
1060+
selectionChips.length === 1 &&
1061+
selected.replace(chipDisplayToken(selectionChips[0]), '').trim().length === 0
1062+
if (soleSelectionChip) {
1063+
e.preventDefault()
1064+
e.clipboardData.setData('text/plain', selected)
1065+
attachSelectionContextToClipboard(e.clipboardData, selectionChips[0])
1066+
return true
1067+
}
1068+
const serialized = serializeSelectionForClipboard(selected, contexts)
10481069
if (serialized === selected) return false
10491070
e.preventDefault()
10501071
e.clipboardData.setData('text/plain', serialized)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
245245
}
246246
}
247247
const removed = prev.filter((p) => !curr.some((c) => contextId(c) === contextId(p)))
248-
if (removed.length > 0) removed.forEach((ctx) => onContextRemoveRef.current?.(ctx))
248+
if (removed.length > 0) removed.forEach((ctx) => onContextRemoveRef.current?.(ctx, curr))
249249
prevSelectedContextsRef.current = curr
250250
}, [editor.contexts])
251251

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,9 +414,18 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
414414
}
415415
}
416416

417-
function handleInitialContextRemove(context: ChatContext) {
417+
function handleInitialContextRemove(context: ChatContext, remaining: ChatContext[]) {
418418
const resolved = resolveResourceFromContext(context)
419419
if (!resolved) return
420+
// A whole-file chip and one or more of its selection chips (or several
421+
// selections of the same file/table) all resolve to the same resource tab.
422+
// Only close the tab once no remaining chip still references it, so removing
423+
// one of several chips doesn't yank a slideover the others still point at.
424+
const stillReferenced = remaining.some((other) => {
425+
const otherResolved = resolveResourceFromContext(other)
426+
return otherResolved?.type === resolved.type && otherResolved.id === resolved.id
427+
})
428+
if (stillReferenced) return
420429
removeResource(resolved.type, resolved.id)
421430
}
422431

0 commit comments

Comments
 (0)