Compare commits

...

1 Commits

Author SHA1 Message Date
ethernet
f96b2f592f fix(desktop): trigger slash command preview only at the start of a message 2026-06-04 20:26:16 -04:00
3 changed files with 79 additions and 7 deletions

View File

@@ -1035,8 +1035,16 @@ export function ChatBar({
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
const submitDraft = () => {
const trimmedDraft = draft.trim()
if (queueEdit) {
exitQueuedEdit('save')
} else if (trimmedDraft && SLASH_COMMAND_RE.test(trimmedDraft) && !attachments.length) {
// Slash commands are dispatched immediately (via onSubmit →
// executeSlashCommand), never queued — even when busy.
triggerHaptic('submit')
clearDraft()
void onSubmit(trimmedDraft)
} else if (busy) {
// Slash commands should execute immediately even while the agent is
// busy — they're client-side operations (/yolo, /skin, /new, /help,
@@ -1045,7 +1053,7 @@ export function ChatBar({
// busy guard for commands that genuinely need an idle session (skill
// /send directives). Queuing them would make every slash command wait
// for the current turn to finish, which is how the TUI never behaves.
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
if (!attachments.length && trimmedDraft) {
const submitted = draft
triggerHaptic('submit')
clearDraft()
@@ -1064,7 +1072,7 @@ export function ChatBar({
}
} else if (!hasComposerPayload && queuedPrompts.length > 0) {
void drainNextQueued()
} else if (draft.trim() || attachments.length > 0) {
} else if (trimmedDraft || attachments.length > 0) {
const submitted = draft
triggerHaptic('submit')
clearDraft()

View File

@@ -75,3 +75,58 @@ describe('blobDedupeKey', () => {
expect(blobDedupeKey(file)).toBe('file:a.png:0:image/png:42')
})
})
describe('detectTrigger', () => {
it('detects slash commands at the start of the input', () => {
const result = detectTrigger('/steer')
expect(result).toEqual({ kind: '/', query: 'steer', tokenLength: 6 })
})
it('detects partial slash commands at the start', () => {
const result = detectTrigger('/st')
expect(result).toEqual({ kind: '/', query: 'st', tokenLength: 3 })
})
it('detects bare slash at the start', () => {
const result = detectTrigger('/')
expect(result).toEqual({ kind: '/', query: '', tokenLength: 1 })
})
it('does NOT trigger slash autocomplete mid-message', () => {
const result = detectTrigger('hello /steer')
expect(result).toBeNull()
})
it('does NOT trigger slash autocomplete after a space mid-message', () => {
const result = detectTrigger('some text /new')
expect(result).toBeNull()
})
it('detects @ mentions at the start of the input', () => {
const result = detectTrigger('@user')
expect(result).toEqual({ kind: '@', query: 'user', tokenLength: 5 })
})
it('detects @ mentions mid-sentence after whitespace', () => {
const result = detectTrigger('hello @user')
expect(result).toEqual({ kind: '@', query: 'user', tokenLength: 5 })
})
it('detects @ mentions at the start of a new line', () => {
const result = detectTrigger('hello\n@user')
expect(result).toEqual({ kind: '@', query: 'user', tokenLength: 5 })
})
it('returns null for plain text without triggers', () => {
expect(detectTrigger('hello world')).toBeNull()
})
it('returns null for empty input', () => {
expect(detectTrigger('')).toBeNull()
})
it('returns null when / is embedded in a word mid-message', () => {
expect(detectTrigger('use path/to/file')).toBeNull()
})
})

View File

@@ -6,7 +6,10 @@ export interface TriggerState {
tokenLength: number
}
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
// Slash commands only trigger autocomplete at the start of the input;
// @ mentions can appear mid-sentence after whitespace.
const SLASH_TRIGGER_RE = /^\/([^\s@/]*)$/
const AT_TRIGGER_RE = /(?:^|[\s])@([^\s@/]*)$/
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
export function blobDedupeKey(blob: Blob): string {
@@ -97,11 +100,17 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
}
export function detectTrigger(textBefore: string): TriggerState | null {
const match = TRIGGER_RE.exec(textBefore)
const slashMatch = SLASH_TRIGGER_RE.exec(textBefore)
if (!match) {
return null
if (slashMatch) {
return { kind: '/', query: slashMatch[1], tokenLength: 1 + slashMatch[1].length }
}
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
const atMatch = AT_TRIGGER_RE.exec(textBefore)
if (atMatch) {
return { kind: '@', query: atMatch[1], tokenLength: 1 + atMatch[1].length }
}
return null
}