mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
* fix(mobile): make native-chat file links and path citations tappable (STA-3331)
- Linkify POSIX absolute paths in chat prose (leading-/ regex alternative;
URL guard now keys off the char before the matched slash)
- Parse agent-style path:line(:col) citations in prose, code spans, and the
open flow; line/column ride into the mobile file preview route
- Route non-web markdown hrefs (file: URIs, relative/absolute paths) to the
file opener instead of silently dropping them; unknown schemes stay dead
- Resolve chat paths against the worktree root, not the terminal's live cwd
- Reuse the terminal tap-to-open flow for chat taps (haptic, preview route,
tab activation with retries) via a shared identity-stable hook, and toast
on misses instead of silent no-ops
- Keep snake_case paths whole (intraword underscores are literal text),
scan bold/italic/strike spans for paths, split trailing punctuation off
autolinks, and let taps land while the composer keyboard is up
* fix(mobile): harden chat file tap handling
* refactor(chat): share native chat href routing
* fix(mobile): detect files directly under path roots
* fix(mobile): keep inline tokens and dunder paths intact around emphasis
Review follow-ups on the chat file-link work:
- A rejected intraword `_` token left the scan index past its closing
underscore, so every inline token between two snake_case words was
swallowed and rendered as literal source — including markdown links,
which became untappable. Rescan from just past the opening delimiter.
- Treat a path separator as an intraword flank so `src/__init__.py` and
`a/__tests__/x.ts` stay whole; previously they rendered as bold plus a
remnant that the new absolute-root pattern turned into a tap on `/x.ts`.
- Bound the `:line(:col)` tail so `src/app.ts:1e3` and `:80%` no longer
parse a line number, while a cited range still opens its first line.
- Route chat tap failures through the composer banner (toast fallback):
chat taps happen with the keyboard up, which covers the toast.
- Drop the tap-handler mirror's dep list; the call site rebuilds its
accessors every render, so it could never skip on a route that
rerenders per keystroke.
* Revert "fix(mobile): keep inline tokens and dunder paths intact around emphasis"
This reverts commit 308bfaf22b.
* fix(mobile): preserve chat file-link parsing and feedback
75 lines
2.3 KiB
TypeScript
75 lines
2.3 KiB
TypeScript
import { isWindowsAbsolutePathLike } from './cross-platform-path'
|
|
import { fileUriToFilesystemPath } from './file-uri-path'
|
|
|
|
export type NativeChatHrefRoute =
|
|
| { kind: 'web'; url: string }
|
|
| { kind: 'file'; pathText: string; line: number | null }
|
|
| { kind: 'none' }
|
|
|
|
const WEB_SCHEME_PATTERN = /^(?:https?|mailto):/i
|
|
const SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/
|
|
|
|
function parseLineFragment(hash: string): number | null {
|
|
if (!hash) {
|
|
return null
|
|
}
|
|
let decoded = hash
|
|
try {
|
|
decoded = decodeURIComponent(hash)
|
|
} catch {
|
|
// Keep the raw fragment when decoding fails.
|
|
}
|
|
const match = /^(?:L|line-?)([1-9]\d*)\b/i.exec(decoded)
|
|
return match ? Number.parseInt(match[1]!, 10) : null
|
|
}
|
|
|
|
function stripQueryAndHash(value: string): { pathText: string; line: number | null } {
|
|
const hashIndex = value.indexOf('#')
|
|
const queryIndex = value.indexOf('?')
|
|
const suffixIndex =
|
|
hashIndex === -1 ? queryIndex : queryIndex === -1 ? hashIndex : Math.min(hashIndex, queryIndex)
|
|
const pathText = suffixIndex === -1 ? value : value.slice(0, suffixIndex)
|
|
const hash =
|
|
hashIndex === -1
|
|
? ''
|
|
: value.slice(hashIndex + 1, queryIndex > hashIndex ? queryIndex : undefined)
|
|
return { pathText, line: parseLineFragment(hash) }
|
|
}
|
|
|
|
function maybeDecodeHrefPath(value: string): string {
|
|
try {
|
|
return decodeURIComponent(value)
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
export function routeNativeChatHref(href: string | null | undefined): NativeChatHrefRoute {
|
|
const trimmed = href?.trim()
|
|
if (!trimmed || trimmed.startsWith('#')) {
|
|
return { kind: 'none' }
|
|
}
|
|
if (WEB_SCHEME_PATTERN.test(trimmed)) {
|
|
return { kind: 'web', url: trimmed }
|
|
}
|
|
if (/^file:/i.test(trimmed)) {
|
|
let url: URL
|
|
try {
|
|
url = new URL(trimmed)
|
|
} catch {
|
|
return { kind: 'none' }
|
|
}
|
|
const pathText = fileUriToFilesystemPath(url)
|
|
if (!pathText) {
|
|
return { kind: 'none' }
|
|
}
|
|
return { kind: 'file', pathText, line: parseLineFragment(url.hash.slice(1)) }
|
|
}
|
|
if (!isWindowsAbsolutePathLike(trimmed) && SCHEME_PATTERN.test(trimmed)) {
|
|
return { kind: 'none' }
|
|
}
|
|
const { pathText, line } = stripQueryAndHash(trimmed)
|
|
const decodedPathText = maybeDecodeHrefPath(pathText)
|
|
return decodedPathText ? { kind: 'file', pathText: decodedPathText, line } : { kind: 'none' }
|
|
}
|