mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
* feat(mobile): native-chat model/session-option picker + shared slash catalog (STA-3332) Piece A — shared slash catalog + send classification: - Mobile composer now serves getVerifiedNativeChatCommands from the shared catalog (agent-aware, with description rows) instead of a hardcoded provider-agnostic list that advertised commands Claude does not have. - classifyNativeChatSend moves to src/shared/native-chat-slash-commands.ts (renderer re-exports keep desktop import paths stable); mobile's send seam now gates optimistic echoes on it, so slash sends no longer create a 'Queued' bubble that no transcript echo can ever retire, and the ack-lost hold only arms for chat sends. Piece B — mobile model/session-option pickers: - New per-tab session-option tracking (state/commands/labels modules) ported from the desktop live flow, reading the shared agent-session-option catalog for Claude AND Codex. - Composer pill row (model + options) opening an inline choice card in the proven Ask-card pattern; applies use catalog modelApply semantics (/model <value> via the existing send path), Codex-style agent-picker entries dispatch the picker command and flip the tab to the terminal view. - Current model seeds from the hook-reported provider model when derivable; typed /model-style commands update tracked state (recordOutgoingCommand parity); dispatched values render as sent-not-confirmed. * fix(mobile): keep session option sends scoped * fix(mobile): synchronize native chat refs after commit * refactor: share native chat session option logic * fix(mobile): keep the live tab's session-option record from eviction `getScopedRecord` returned an existing record without re-inserting it, so the per-tab record map evicted by insertion order rather than recency. A long-lived active tab is the oldest key, so crossing the 32-scope cap silently dropped its tracked model and reset the pill to "Model". Desktop's scope cache does delete-then-set for exactly this reason. Also moves the shared session-option tests to src/shared so the root suite runs them (they only exercised src/shared logic the Electron renderer consumes, but sat under mobile/ where only mobile's vitest project sees them), and restores two "why" comments dropped while extracting the shared modules. * fix(mobile): stop a stale session-start report reverting a model pick Re-entering a chat tab re-delivers the same `agentStatus.model`, and the reported-model effect re-applied it unconditionally — so picking a model, moving to another tab, and coming back reverted the pill to the model the agent reported at session start, which cannot have observed the `/model` sent after it. The status stream reconnecting had the same effect. A report is now only treated as evidence when the matched catalog id CHANGES for that scope; a genuinely new report still supersedes a local pick. Mobile has no screen read to confirm a switch against, so the repeat is all we can key off. * fix(mobile): close four session-option picker defects found in review D1 — a picker apply could interleave with a composer send. The composer already blocks a text send while an apply is dispatching, but not the reverse: the host spaces a send's body and its Enter ~500ms apart, so an apply tapped inside that window was submitted as part of the user's prompt, and the pill then claimed a model change that never ran as a command. The pickers render inside the composer, so they now take its in-flight state directly — the same guard, mirrored. D2 — an option was filed under the wrong model. `setTrackedSessionOption` resolves the owning model when it commits, not when the command was built, and the report effect mutates the same record off-queue. A report landing mid-dispatch therefore recorded `/effort low` against the model it switched TO. Ports desktop's supersession guard, which skips the commit when the baseline moved. D3 — a command template's prefix also matches prose that starts with it, so "/model is a weird word" tracked that prose as the current model, rendered it as the pill label, and matched no catalog model, dropping every per-model option. Parsed values are now canonicalized against the catalog; a typed value containing whitespace is treated as a prompt rather than a command. Perf — `/` on a Codex tab returned all 45 commands into a non-virtualized ScrollView showing ~5, re-reconciled on every streaming tick above the transcript. Capped at 12. Also splits the row primitives out of MobileNativeChatSessionOptionPickers.tsx, which the D1 guard pushed to 402 effective lines against a 400 cap. * refactor: share the session-option display ordering CATEGORY_ORDER and the non-model sort were byte-identical in NativeChatSessionOptionPickers.tsx and mobile's labels module — pure logic with no i18n in it, so there was no reason for two copies that can drift. Both now call sortNativeChatSessionOptions from the shared snapshot module. * refactor(mobile): align model picker layout * style(mobile): round native chat composer * fix(mobile): inset rounded chat composer
124 lines
4.1 KiB
TypeScript
124 lines
4.1 KiB
TypeScript
// Why: the composer offers two kinds of autocomplete — `@file` mentions and
|
|
// `/slash` commands. Detection is pure (text + cursor → active trigger) so it's
|
|
// unit-testable and the composer stays a thin view over it.
|
|
|
|
import type { SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands'
|
|
|
|
export type AutocompleteKind = 'file' | 'slash'
|
|
|
|
export type AutocompleteTrigger = {
|
|
kind: AutocompleteKind
|
|
/** The query typed after the trigger char (may be empty). */
|
|
query: string
|
|
/** Index of the trigger char in the text (inclusive). */
|
|
start: number
|
|
/** Index just past the cursor / token end (exclusive) — the replace span end. */
|
|
end: number
|
|
}
|
|
|
|
const TOKEN_CHAR = /[^\s]/
|
|
|
|
/** Detect an active autocomplete trigger at the cursor. A `@` mention triggers
|
|
* anywhere it follows whitespace/start; a `/` command only at the very start of
|
|
* the input (slash commands are line-leading). Returns null when the cursor is
|
|
* not inside such a token. */
|
|
export function detectAutocompleteTrigger(
|
|
text: string,
|
|
cursor: number
|
|
): AutocompleteTrigger | null {
|
|
const pos = Math.max(0, Math.min(cursor, text.length))
|
|
// Walk left from the cursor over non-whitespace token chars to find the trigger.
|
|
let i = pos - 1
|
|
while (i >= 0 && TOKEN_CHAR.test(text[i]!)) {
|
|
i--
|
|
}
|
|
const triggerIndex = i + 1
|
|
const triggerChar = text[triggerIndex]
|
|
if (triggerChar !== '@' && triggerChar !== '/') {
|
|
return null
|
|
}
|
|
const before = triggerIndex === 0 ? '' : text[triggerIndex - 1]!
|
|
if (triggerChar === '/' && triggerIndex !== 0) {
|
|
// Slash commands are only offered at the very start of the message.
|
|
return null
|
|
}
|
|
if (triggerChar === '@' && triggerIndex !== 0 && !/\s/.test(before)) {
|
|
return null
|
|
}
|
|
const query = text.slice(triggerIndex + 1, pos)
|
|
// A space in the query means the token already closed.
|
|
if (/\s/.test(query)) {
|
|
return null
|
|
}
|
|
return {
|
|
kind: triggerChar === '@' ? 'file' : 'slash',
|
|
query,
|
|
start: triggerIndex,
|
|
end: pos
|
|
}
|
|
}
|
|
|
|
/** Replace the trigger span with `value`, leaving a trailing space and the
|
|
* cursor after it. Returns the new text and the new cursor position. */
|
|
export function applyAutocomplete(
|
|
text: string,
|
|
trigger: AutocompleteTrigger,
|
|
value: string
|
|
): { text: string; cursor: number } {
|
|
const inserted = `${value} `
|
|
const next = text.slice(0, trigger.start) + inserted + text.slice(trigger.end)
|
|
return { text: next, cursor: trigger.start + inserted.length }
|
|
}
|
|
|
|
/** Rank suggestions for a query: case-insensitive, prefix matches first, then
|
|
* substring matches, capped. Used for both file paths and command names. */
|
|
export function rankSuggestions(candidates: readonly string[], query: string, limit = 8): string[] {
|
|
const q = query.toLowerCase()
|
|
if (q.length === 0) {
|
|
return candidates.slice(0, limit)
|
|
}
|
|
const prefix: string[] = []
|
|
const substring: string[] = []
|
|
for (const candidate of candidates) {
|
|
const lower = candidate.toLowerCase()
|
|
const base = lower.split('/').pop() ?? lower
|
|
if (lower.startsWith(q) || base.startsWith(q)) {
|
|
prefix.push(candidate)
|
|
} else if (lower.includes(q)) {
|
|
substring.push(candidate)
|
|
}
|
|
if (prefix.length >= limit) {
|
|
break
|
|
}
|
|
}
|
|
return [...prefix, ...substring].slice(0, limit)
|
|
}
|
|
|
|
/** Rank an agent's slash commands: prefix matches first, then substring, in
|
|
* catalog order. A bare `/` shows the whole catalog (desktop parity) — the
|
|
* suggestion list scrolls, so the cap only guards against absurd catalogs. */
|
|
export function rankSlashCommandSuggestions(
|
|
commands: readonly SlashCommandSuggestion[],
|
|
query: string,
|
|
limit = 50
|
|
): SlashCommandSuggestion[] {
|
|
const q = query.toLowerCase()
|
|
if (q.length === 0) {
|
|
return commands.slice(0, limit)
|
|
}
|
|
const prefix: SlashCommandSuggestion[] = []
|
|
const substring: SlashCommandSuggestion[] = []
|
|
for (const command of commands) {
|
|
const lower = command.name.toLowerCase()
|
|
if (lower.startsWith(q)) {
|
|
prefix.push(command)
|
|
} else if (lower.includes(q)) {
|
|
substring.push(command)
|
|
}
|
|
if (prefix.length >= limit) {
|
|
break
|
|
}
|
|
}
|
|
return [...prefix, ...substring].slice(0, limit)
|
|
}
|