Skip to content

Commit 3bb5d6c

Browse files
committed
fix(chat): stop a removed chip lingering when its label prefixes another
Cursor Bugbot: the mention sync tests each label with a lookahead that rejects only word characters, so '-', ')' and space all let a shorter label match INSIDE a longer token. '@notes.md:12' matches within '@notes.md:12-40', and '@sales (3 rows)' within '@sales (3 rows) (2)'. Deleting the shorter chip left its context attached, and it was still sent with the message. The label class is pre-existing, but this PR made it routine: line ranges and uniqueContextLabel ordinals generate prefix pairs for any two selections of the same file or table. Fixed at the sync rather than by reshaping labels to dodge the prefix — a label format chosen to avoid a matcher bug would just relocate it. Contexts are now tested longest-label-first, and each matched token is blanked before shorter labels are tested, so every context is judged against text its own token owns. Blanked in place, not removed, so the (^|\s) boundary of whatever sits next to it is preserved; prev order is still what's returned. Shared with the workflow copilot input, so tests cover both directions: the two prefix pairs are dropped when only the longer token remains, both survive when both tokens are present, order is preserved, and trailing punctuation after a mention still keeps its chip.
1 parent db183b5 commit 3bb5d6c

2 files changed

Lines changed: 127 additions & 5 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
7+
import { useContextManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management'
8+
import type { ChatContext } from '@/stores/panel'
9+
10+
let container: HTMLDivElement
11+
let root: Root
12+
let latest: ReturnType<typeof useContextManagement>
13+
14+
/** Renders the hook with a fixed message and initial contexts, exposing its result. */
15+
function renderSync(message: string, initialContexts: ChatContext[]) {
16+
function Host() {
17+
latest = useContextManagement({ message, initialContexts })
18+
return null
19+
}
20+
act(() => {
21+
root.render(<Host />)
22+
})
23+
}
24+
25+
const fileSelection = (label: string): ChatContext => ({
26+
kind: 'file_selection',
27+
fileId: 'f1',
28+
fileName: 'notes.md',
29+
label,
30+
text: 'passage',
31+
})
32+
33+
const tableSelection = (label: string, rowIds: string[]): ChatContext => ({
34+
kind: 'table_selection',
35+
tableId: 't1',
36+
tableName: 'Sales',
37+
label,
38+
rowIds,
39+
})
40+
41+
describe('useContextManagement label sync', () => {
42+
beforeEach(() => {
43+
container = document.createElement('div')
44+
document.body.appendChild(container)
45+
root = createRoot(container)
46+
})
47+
48+
afterEach(() => {
49+
act(() => root.unmount())
50+
container.remove()
51+
})
52+
53+
it('drops a chip whose label is only a prefix of a surviving one', () => {
54+
// `@notes.md:12` matches inside `@notes.md:12-40` — the token lookahead
55+
// rejects word characters but not `-`, so only the longer chip is really
56+
// present and the shorter must not linger and get sent.
57+
renderSync('look at @notes.md:12-40 please', [
58+
fileSelection('notes.md:12'),
59+
fileSelection('notes.md:12-40'),
60+
])
61+
62+
expect(latest.selectedContexts.map((c) => c.label)).toEqual(['notes.md:12-40'])
63+
})
64+
65+
it('drops an un-ordinalized chip when only its ordinal twin remains', () => {
66+
renderSync('see @Sales (3 rows) (2) here', [
67+
tableSelection('Sales (3 rows)', ['r1', 'r2', 'r3']),
68+
tableSelection('Sales (3 rows) (2)', ['r7', 'r8', 'r9']),
69+
])
70+
71+
expect(latest.selectedContexts.map((c) => c.label)).toEqual(['Sales (3 rows) (2)'])
72+
})
73+
74+
it('keeps both when both tokens are present', () => {
75+
renderSync('@notes.md:12 and @notes.md:12-40', [
76+
fileSelection('notes.md:12'),
77+
fileSelection('notes.md:12-40'),
78+
])
79+
80+
expect(latest.selectedContexts.map((c) => c.label).sort()).toEqual([
81+
'notes.md:12',
82+
'notes.md:12-40',
83+
])
84+
})
85+
86+
it('preserves the original context order, not the length-sorted one', () => {
87+
renderSync('@notes.md:12 and @notes.md:12-40', [
88+
fileSelection('notes.md:12'),
89+
fileSelection('notes.md:12-40'),
90+
])
91+
92+
expect(latest.selectedContexts.map((c) => c.label)).toEqual(['notes.md:12', 'notes.md:12-40'])
93+
})
94+
95+
it('still tolerates trailing punctuation after a mention', () => {
96+
renderSync('ask @notes.md:12-40, then stop', [fileSelection('notes.md:12-40')])
97+
98+
expect(latest.selectedContexts).toHaveLength(1)
99+
})
100+
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,20 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
6868
setSelectedContexts((prev) => {
6969
if (prev.length === 0) return prev
7070

71-
const filtered = prev.filter((c) => {
72-
if (!c.label) return false
73-
// Check for slash command tokens or mention tokens based on kind.
71+
// Longest label first, masking each token once it matches. One label can be
72+
// a prefix of another — `notes.md:12` of `notes.md:12-40`, `Sales (3 rows)`
73+
// of `Sales (3 rows) (2)` — and the lookahead below only rejects a
74+
// following word character, so `-`, `)` and space all let the shorter
75+
// pattern match INSIDE the longer token. Without masking, deleting the
76+
// shorter chip would leave its context attached and still send it.
77+
const byLabelLengthDesc = [...prev].sort(
78+
(a, b) => (b.label?.length ?? 0) - (a.label?.length ?? 0)
79+
)
80+
let unclaimed = message
81+
const present = new Set<ChatContext>()
82+
83+
for (const c of byLabelLengthDesc) {
84+
if (!c.label) continue
7485
// The trailing lookahead `(?![A-Za-z0-9_])` accepts any word-boundary
7586
// — whitespace, end-of-string, or punctuation — so `@Slack.` and
7687
// `@Slack,` survive the sync. A strict `(\s|$)` here would strip
@@ -89,8 +100,19 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
89100
const tokenPattern = new RegExp(
90101
`(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(?![A-Za-z0-9_])`
91102
)
92-
return tokenPattern.test(message)
93-
})
103+
const match = tokenPattern.exec(unclaimed)
104+
if (!match) continue
105+
present.add(c)
106+
// Blank the claimed span (same length, so later indices stay valid)
107+
// rather than removing it, keeping the `(^|\s)` boundary intact for
108+
// whatever sits next to it.
109+
unclaimed =
110+
unclaimed.slice(0, match.index) +
111+
' '.repeat(match[0].length) +
112+
unclaimed.slice(match.index + match[0].length)
113+
}
114+
115+
const filtered = prev.filter((c) => present.has(c))
94116
return filtered.length === prev.length ? prev : filtered
95117
})
96118
}, [message])

0 commit comments

Comments
 (0)