Merge branch 'main' into ethie/bundled-node-path-windows-layout
#76499 landed the npm floor as >=11.17.0 on a Node >=20 baseline. This branch takes the other half of the same constraint: the vendored Node 26 tree now installs npm 12 into itself, so the toolchain satisfies the stricter floor rather than the manifest relaxing to meet the tarball. Resolved package.json + package-lock.json to node >=26.0.0 / npm >=12.0.0 and refreshed npm_engine.py's illustrative range to match. website/'s mirror keeps #76499's >=11.17.0 — it is not a root workspace and builds on its own Node.
This commit is contained in:
commit
8de9c16b7c
|
|
@ -98,6 +98,12 @@ pub fn update_in_progress_marker() -> PathBuf {
|
|||
/// that path), where copying onto ourselves would be a Windows sharing
|
||||
/// violation. Best-effort: a failure here must not fail the install, so the
|
||||
/// caller logs and continues.
|
||||
///
|
||||
/// NOTE: because of that no-op, a user's staged installer is only ever written
|
||||
/// by a full install/repair. Every later `--update` runs the ORIGINAL binary,
|
||||
/// so an installer-protocol change can strand the whole installed base on a
|
||||
/// binary that predates it (see `restage_from_checkout`, which repairs this
|
||||
/// from the freshly-updated checkout).
|
||||
pub fn copy_self_to_hermes_home() -> std::io::Result<()> {
|
||||
let src = std::env::current_exe()?;
|
||||
let dest = installer_dest();
|
||||
|
|
|
|||
|
|
@ -201,7 +201,11 @@ import {
|
|||
sandboxPreflight
|
||||
} from './update-relaunch'
|
||||
import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote'
|
||||
import { resolveStagedUpdaterBinary, spawnUpdaterProcess } from './updater-process'
|
||||
import {
|
||||
resolveStagedUpdaterBinary,
|
||||
spawnUpdaterProcess,
|
||||
stagedUpdaterSupportsPrewrittenMarker
|
||||
} from './updater-process'
|
||||
import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers } from './venv-blocker-scan'
|
||||
import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace'
|
||||
import { createWakeIndicatorWindowController } from './wake-indicator-window'
|
||||
|
|
@ -3003,8 +3007,20 @@ async function applyUpdates(opts = {}) {
|
|||
// the venv. By writing the marker ourselves the renderer's
|
||||
// waitForUpdateToFinish() gate sees a live update and parks instead.
|
||||
// The updater overwrites this with its own PID later; same format.
|
||||
if (Number.isInteger(child.pid)) {
|
||||
//
|
||||
// SKIPPED for pre-#74782 staged updaters: those have no self-PID
|
||||
// exclusion, so they read this very marker as a foreign live owner and
|
||||
// abort with "Another Hermes update is already running (PID <itself>)" —
|
||||
// an unbreakable loop, because the update that would replace the stale
|
||||
// binary is the one being refused. Losing the anti-respawn hardening is
|
||||
// strictly better than never updating again, and the updater still writes
|
||||
// its own marker moments later.
|
||||
if (Number.isInteger(child.pid) && stagedUpdaterSupportsPrewrittenMarker(updater)) {
|
||||
writeUpdateMarker(HERMES_HOME, child.pid)
|
||||
} else if (Number.isInteger(child.pid)) {
|
||||
rememberLog(
|
||||
`[updates] skipping marker pre-write: staged updater predates self-adopt (${updater}); it would refuse its own claim`
|
||||
)
|
||||
}
|
||||
|
||||
rememberLog(`[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim`)
|
||||
|
|
@ -3091,9 +3107,15 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
|||
|
||||
// Same marker pre-write as applyUpdates — see comment there. The recovery
|
||||
// hand-off has the same window where the renderer can respawn a backend
|
||||
// before the updater writes its own marker.
|
||||
if (Number.isInteger(child.pid)) {
|
||||
// before the updater writes its own marker, and the same stale-updater
|
||||
// exclusion: a pre-#74782 binary would refuse its own pre-written claim and
|
||||
// strand the very recovery meant to heal the install.
|
||||
if (Number.isInteger(child.pid) && stagedUpdaterSupportsPrewrittenMarker(updater)) {
|
||||
writeUpdateMarker(HERMES_HOME, child.pid)
|
||||
} else if (Number.isInteger(child.pid)) {
|
||||
rememberLog(
|
||||
`[bootstrap] skipping marker pre-write: staged updater predates self-adopt (${updater}); it would refuse its own claim`
|
||||
)
|
||||
}
|
||||
|
||||
rememberLog(
|
||||
|
|
|
|||
|
|
@ -510,7 +510,10 @@ test('connect() respawns when the requested remote profile differs from the lock
|
|||
)
|
||||
|
||||
assert.equal(result.reused, false)
|
||||
assert.ok(ssh.calls.some(c => /setsid/.test(c)), 'profile mismatch must spawn a fresh dashboard')
|
||||
assert.ok(
|
||||
ssh.calls.some(c => /setsid/.test(c)),
|
||||
'profile mismatch must spawn a fresh dashboard'
|
||||
)
|
||||
})
|
||||
|
||||
test('connect() respawns when the lockfile hermesPath differs from the resolved path', async () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,65 @@ import path from 'node:path'
|
|||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { resolveStagedUpdaterBinary, spawnUpdaterProcess } from './updater-process'
|
||||
import {
|
||||
MARKER_SELF_ADOPT_EPOCH_MS,
|
||||
resolveStagedUpdaterBinary,
|
||||
spawnUpdaterProcess,
|
||||
stagedUpdaterSupportsPrewrittenMarker
|
||||
} from './updater-process'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
test('stagedUpdaterSupportsPrewrittenMarker rejects installers predating the self-adopt fix', () => {
|
||||
// The real-world trap: an installer staged at first install months ago, never
|
||||
// refreshed because copy_self_to_hermes_home no-ops during --update.
|
||||
assert.equal(
|
||||
stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', {
|
||||
stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS - 60 * DAY_MS
|
||||
}),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('stagedUpdaterSupportsPrewrittenMarker accepts installers from the fix onward', () => {
|
||||
assert.equal(
|
||||
stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', {
|
||||
stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS
|
||||
}),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', {
|
||||
stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS + 30 * DAY_MS
|
||||
}),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('stagedUpdaterSupportsPrewrittenMarker treats an unreadable mtime as unsupported', () => {
|
||||
// Bias toward the path that can always make progress: a skipped pre-write
|
||||
// loses anti-respawn hardening, a wedged updater can never update again.
|
||||
assert.equal(
|
||||
stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', {
|
||||
stagedMtimeMs: () => null
|
||||
}),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveStagedUpdaterBinary still returns a stale staged updater on Windows', () => {
|
||||
// Staleness gates only the marker PRE-WRITE, never the hand-off itself:
|
||||
// the stale binary is the only updater these users have, and it works fine
|
||||
// once it is allowed to write its own claim.
|
||||
assert.equal(
|
||||
resolveStagedUpdaterBinary('C:\\Hermes', {
|
||||
fileExists: () => true,
|
||||
isWindows: true,
|
||||
stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS - 60 * DAY_MS
|
||||
}),
|
||||
path.join('C:\\Hermes', 'hermes-setup.exe')
|
||||
)
|
||||
})
|
||||
|
||||
test('spawnUpdaterProcess hides the updater console and detaches the child on Windows', () => {
|
||||
const calls: Array<{ args: string[]; command: string; options: SpawnOptions }> = []
|
||||
|
|
|
|||
|
|
@ -12,8 +12,20 @@ export interface UpdaterChild {
|
|||
export interface ResolveStagedUpdaterBinaryDeps {
|
||||
isWindows?: boolean
|
||||
fileExists?: (candidate: string) => boolean
|
||||
stagedMtimeMs?: (candidate: string) => number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Staged installers older than this have no self-PID exclusion in
|
||||
* `UpdateMarkerGuard::acquire` and will refuse an update whose marker was
|
||||
* pre-written on their behalf.
|
||||
*
|
||||
* The self-adopt fix landed in #74782 / 160586ff8 (2026-07-30 17:57 +0700).
|
||||
* We compare against the start of 2026-07-31 UTC so the boundary is
|
||||
* unambiguous for binaries staged that same day.
|
||||
*/
|
||||
export const MARKER_SELF_ADOPT_EPOCH_MS = Date.UTC(2026, 6, 31)
|
||||
|
||||
function stagedFileExists(candidate: string): boolean {
|
||||
try {
|
||||
return statSync(candidate).isFile()
|
||||
|
|
@ -22,6 +34,14 @@ function stagedFileExists(candidate: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function stagedFileMtimeMs(candidate: string): number | null {
|
||||
try {
|
||||
return statSync(candidate).mtimeMs
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which staged installer binary — if any — may be handed an update.
|
||||
*
|
||||
|
|
@ -61,6 +81,37 @@ export function resolveStagedUpdaterBinary(
|
|||
return fileExists(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the staged installer is new enough to survive a pre-written marker.
|
||||
*
|
||||
* `copy_self_to_hermes_home` deliberately no-ops during `--update`
|
||||
* (apps/bootstrap-installer/src-tauri/src/paths.rs), so the binary staged by a
|
||||
* user's ORIGINAL install orchestrates every later update — forever. Installers
|
||||
* predating #74782 have no self-PID exclusion in `UpdateMarkerGuard::acquire`,
|
||||
* so when the desktop pre-writes the marker naming that very updater, the
|
||||
* updater reads its own claim as a foreign live owner and aborts with
|
||||
* "Another Hermes update is already running (PID <itself>, started 1s ago)" —
|
||||
* the observed infinite "Install didn't finish" loop. Skipping the pre-write
|
||||
* for those binaries lets them acquire cleanly and run `hermes update`, which
|
||||
* pulls the permanent fixes. See shouldPrewriteUpdateMarker.
|
||||
*
|
||||
* We cannot ask the binary its version without executing it, so use its mtime:
|
||||
* the installer is written to HERMES_HOME at install/repair time, making mtime
|
||||
* a faithful stamp of which installer generation produced it.
|
||||
*
|
||||
* Unreadable mtime counts as UNSUPPORTED — the pre-write is a best-effort
|
||||
* hardening, while a wedged updater is unrecoverable, so we bias toward the
|
||||
* path that can always make progress.
|
||||
*/
|
||||
export function stagedUpdaterSupportsPrewrittenMarker(
|
||||
candidate: string,
|
||||
deps: ResolveStagedUpdaterBinaryDeps = {}
|
||||
): boolean {
|
||||
const mtimeMs = (deps.stagedMtimeMs ?? stagedFileMtimeMs)(candidate)
|
||||
|
||||
return typeof mtimeMs === 'number' && Number.isFinite(mtimeMs) && mtimeMs >= MARKER_SELF_ADOPT_EPOCH_MS
|
||||
}
|
||||
|
||||
export interface SpawnUpdaterProcessDeps {
|
||||
isWindows?: boolean
|
||||
spawnProcess?: (command: string, args: string[], options: SpawnOptions) => UpdaterChild
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
|
||||
import { ComposerDirectiveActions } from './directive-actions'
|
||||
import { refChipElement } from './rich-editor'
|
||||
|
||||
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||
|
||||
const openSession = vi.fn()
|
||||
|
||||
vi.mock('@/app/open-session', () => ({ openSession: (...args: unknown[]) => openSession(...args) }))
|
||||
|
||||
/** A live contenteditable holding real chips, with the watcher bound to it —
|
||||
* the same pair both composers mount. */
|
||||
function mountEditor(chips: { kind: string; value: string }[]) {
|
||||
const editor = document.createElement('div')
|
||||
|
||||
editor.contentEditable = 'true'
|
||||
editor.append(...chips.map(chip => refChipElement(chip.kind, `\`${chip.value}\``)))
|
||||
document.body.append(editor)
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<ComposerDirectiveActions editorRef={{ current: editor }} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
function chips(editor: HTMLElement, kind: string) {
|
||||
return Array.from(editor.querySelectorAll(`[data-ref-kind="${kind}"]`))
|
||||
}
|
||||
|
||||
function hover(node: Element) {
|
||||
fireEvent.pointerOver(node, { bubbles: true })
|
||||
}
|
||||
|
||||
/** The reference the visible action pill points at, or null when there is none. */
|
||||
function pillValue() {
|
||||
return document.querySelector('[data-slot="composer-directive-action"]')?.getAttribute('data-value') ?? null
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.body.replaceChildren()
|
||||
delete desktopWindow.hermesDesktop
|
||||
openSession.mockReset()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('ComposerDirectiveActions', () => {
|
||||
it('offers an action for a hovered actionable chip', () => {
|
||||
const editor = mountEditor([{ kind: 'url', value: 'https://example.com/docs' }])
|
||||
|
||||
expect(pillValue()).toBeNull()
|
||||
|
||||
hover(chips(editor, 'url')[0]!)
|
||||
|
||||
expect(pillValue()).toBe('https://example.com/docs')
|
||||
})
|
||||
|
||||
it('opens a url externally rather than navigating the app', () => {
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop']
|
||||
|
||||
const editor = mountEditor([{ kind: 'url', value: 'https://example.com/docs' }])
|
||||
|
||||
hover(chips(editor, 'url')[0]!)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(openExternal).toHaveBeenCalledWith('https://example.com/docs')
|
||||
expect(pillValue()).toBeNull()
|
||||
})
|
||||
|
||||
it('runs the kind-specific action — a session chip opens the session', async () => {
|
||||
const editor = mountEditor([{ kind: 'session', value: 'default/20260722_204335_d62c16' }])
|
||||
|
||||
hover(chips(editor, 'session')[0]!)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
// openSessionRef lazy-imports the navigator, so the call lands a tick later.
|
||||
await vi.waitFor(() =>
|
||||
expect(openSession).toHaveBeenCalledWith('20260722_204335_d62c16', expect.any(Function), 'tab')
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves kinds with no action alone', () => {
|
||||
const editor = mountEditor([{ kind: 'file', value: 'src/main.tsx' }])
|
||||
|
||||
hover(chips(editor, 'file')[0]!)
|
||||
|
||||
expect(pillValue()).toBeNull()
|
||||
})
|
||||
|
||||
it('follows the pointer from one chip to the next', () => {
|
||||
const editor = mountEditor([
|
||||
{ kind: 'url', value: 'https://one.example' },
|
||||
{ kind: 'url', value: 'https://two.example' }
|
||||
])
|
||||
|
||||
const [first, second] = chips(editor, 'url')
|
||||
|
||||
hover(first!)
|
||||
|
||||
expect(pillValue()).toBe('https://one.example')
|
||||
|
||||
hover(second!)
|
||||
|
||||
expect(pillValue()).toBe('https://two.example')
|
||||
})
|
||||
|
||||
it('keeps the pill up while the pointer crosses onto it', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const editor = mountEditor([{ kind: 'url', value: 'https://example.com' }])
|
||||
const chip = chips(editor, 'url')[0]!
|
||||
|
||||
hover(chip)
|
||||
fireEvent.pointerOut(chip, { relatedTarget: document.body })
|
||||
fireEvent.mouseEnter(screen.getByRole('button').parentElement!)
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
expect(pillValue()).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('binds to the document so a late-attached editor still gets the affordance', () => {
|
||||
// The edit composer's editor isn't reliably in the DOM when the effect
|
||||
// first runs; a document listener that reads the editor lazily works
|
||||
// regardless — this is the whole reason it binds to document, not editor.
|
||||
const editor = document.createElement('div')
|
||||
|
||||
editor.contentEditable = 'true'
|
||||
editor.append(refChipElement('url', '`https://late.example`'))
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<ComposerDirectiveActions editorRef={{ current: editor }} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
// Editor attached AFTER mount.
|
||||
document.body.append(editor)
|
||||
hover(editor.querySelector('[data-ref-kind="url"]')!)
|
||||
|
||||
expect(pillValue()).toBe('https://late.example')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Hover actions for directive chips in a composer.
|
||||
*
|
||||
* A directive chip (`@url:`, `@session:`, …) reads as the thing it points at
|
||||
* and is coloured like one, but a composer is an editor — a click inside the
|
||||
* contenteditable only places the caret, so there's no way to *act* on the
|
||||
* reference. Instead, hovering a chip whose kind has an action floats a small
|
||||
* pill above it that runs it.
|
||||
*
|
||||
* The kind → action table (`DIRECTIVE_ACTIONS`) lives in `directive-text`, so
|
||||
* it is shared with the sent-message chip: one entry lights up both surfaces.
|
||||
*/
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { DIRECTIVE_ACTIONS, type DirectiveAction } from '@/components/assistant-ui/directive-text'
|
||||
import { composerFloatingPill } from '@/components/chat/composer-dock'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Moving between the chip and the pill crosses a gap where neither is hovered.
|
||||
* Short enough that it still reads as instant on the way out. */
|
||||
const HIDE_DELAY_MS = 120
|
||||
|
||||
/** The actionable directive chip under `target` that also belongs to `editor`,
|
||||
* if there is one. */
|
||||
function actionableChipAt(target: EventTarget | null, editor: HTMLElement): HTMLElement | null {
|
||||
const chip = target instanceof Element ? target.closest<HTMLElement>('[data-ref-kind]') : null
|
||||
const kind = chip?.dataset.refKind
|
||||
|
||||
return chip && kind && chip.dataset.refId && editor.contains(chip) && DIRECTIVE_ACTIONS[kind] ? chip : null
|
||||
}
|
||||
|
||||
interface Anchor {
|
||||
action: DirectiveAction
|
||||
chip: HTMLElement
|
||||
left: number
|
||||
top: number
|
||||
value: string
|
||||
}
|
||||
|
||||
function anchorFor(chip: HTMLElement): Anchor | null {
|
||||
const value = chip.dataset.refId
|
||||
const action = chip.dataset.refKind ? DIRECTIVE_ACTIONS[chip.dataset.refKind] : undefined
|
||||
|
||||
if (!value || !action || !chip.isConnected) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rect = chip.getBoundingClientRect()
|
||||
|
||||
return { action, chip, left: rect.left, top: rect.top, value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the action pill for whichever actionable chip in `editorRef` is
|
||||
* hovered.
|
||||
*
|
||||
* Listeners bind to `document`, not the editor, so mount timing can't strand
|
||||
* them: the edit composer's contenteditable isn't reliably attached when this
|
||||
* effect first runs, and a document listener that reads the editor lazily works
|
||||
* regardless. Each instance filters to its own editor, so the docked and edit
|
||||
* composers never show two pills for one chip.
|
||||
*/
|
||||
export function ComposerDirectiveActions({ editorRef }: { editorRef: RefObject<HTMLElement | null> }) {
|
||||
const { t } = useI18n()
|
||||
const [anchor, setAnchor] = useState<Anchor | null>(null)
|
||||
const hideTimerRef = useRef<number | undefined>(undefined)
|
||||
|
||||
const cancelHide = useCallback(() => {
|
||||
window.clearTimeout(hideTimerRef.current)
|
||||
}, [])
|
||||
|
||||
const hideSoon = useCallback(() => {
|
||||
cancelHide()
|
||||
hideTimerRef.current = window.setTimeout(() => setAnchor(null), HIDE_DELAY_MS)
|
||||
}, [cancelHide])
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerOver = (event: PointerEvent) => {
|
||||
const editor = editorRef.current
|
||||
const chip = editor && actionableChipAt(event.target, editor)
|
||||
|
||||
if (!chip) {
|
||||
return
|
||||
}
|
||||
|
||||
cancelHide()
|
||||
setAnchor(current => (current?.chip === chip ? current : anchorFor(chip)))
|
||||
}
|
||||
|
||||
const onPointerOut = (event: PointerEvent) => {
|
||||
const editor = editorRef.current
|
||||
const chip = editor && actionableChipAt(event.target, editor)
|
||||
|
||||
// A move within the same chip (its icon → its label) is not a leave.
|
||||
if (chip && editor && chip === actionableChipAt(event.relatedTarget, editor)) {
|
||||
return
|
||||
}
|
||||
|
||||
hideSoon()
|
||||
}
|
||||
|
||||
// The chip can move or vanish under a parked pointer: the editor scrolls,
|
||||
// the window resizes, or the user deletes the reference the pill points at.
|
||||
const reanchor = () => setAnchor(current => (current ? anchorFor(current.chip) : null))
|
||||
|
||||
document.addEventListener('pointerover', onPointerOver)
|
||||
document.addEventListener('pointerout', onPointerOut)
|
||||
window.addEventListener('scroll', reanchor, true)
|
||||
window.addEventListener('resize', reanchor)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerover', onPointerOver)
|
||||
document.removeEventListener('pointerout', onPointerOut)
|
||||
window.removeEventListener('scroll', reanchor, true)
|
||||
window.removeEventListener('resize', reanchor)
|
||||
window.clearTimeout(hideTimerRef.current)
|
||||
}
|
||||
}, [cancelHide, editorRef, hideSoon])
|
||||
|
||||
if (!anchor) {
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed z-(--z-over-modal) -translate-y-full pb-1"
|
||||
data-slot="composer-directive-action"
|
||||
data-value={anchor.value}
|
||||
onMouseEnter={cancelHide}
|
||||
onMouseLeave={hideSoon}
|
||||
style={{ left: anchor.left, top: anchor.top }}
|
||||
>
|
||||
<button
|
||||
className={cn(composerFloatingPill, 'shadow-nous')}
|
||||
onClick={() => {
|
||||
anchor.action.run(anchor.value)
|
||||
setAnchor(null)
|
||||
}}
|
||||
// Never let the press reach the editor: mousedown inside a
|
||||
// contenteditable moves the caret and can collapse a selection the user
|
||||
// still wants, and the edit composer treats a blur as "cancel".
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon className="shrink-0 opacity-70" name={anchor.action.icon} size="0.75rem" />
|
||||
{anchor.action.label(t)}
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { composerPlainText, normalizeComposerEditorDom, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import {
|
||||
beginComposerComposition,
|
||||
composerPlainText,
|
||||
deleteChipBeforeCaret,
|
||||
normalizeComposerEditorDom,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
function editor(): HTMLDivElement {
|
||||
const el = document.createElement('div')
|
||||
|
|
@ -131,4 +138,133 @@ describe('an emptied composer shows its placeholder again', () => {
|
|||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
|
||||
})
|
||||
|
||||
// Chromium leaves zero-length text nodes behind whenever an edit lands next
|
||||
// to a contenteditable=false chip. They render as nothing, so an editor
|
||||
// holding only those is empty to the user — counting them as contents left
|
||||
// the placeholder hidden under a composer that looked blank.
|
||||
it('advertises emptiness for an editor holding only zero-length text nodes', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(document.createTextNode(''), document.createTextNode(''))
|
||||
normalizeComposerEditorDom(el)
|
||||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not advertise emptiness while real text sits beside that litter', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(document.createTextNode(''), document.createTextNode('one'), document.createTextNode(''))
|
||||
normalizeComposerEditorDom(el)
|
||||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
|
||||
})
|
||||
|
||||
// Input events are skipped for the duration of an IME composition, so nothing
|
||||
// else clears the marker until it ends — the hint would sit behind the
|
||||
// hiragana the user is composing (#75960).
|
||||
it('hides the placeholder before IME preedit text starts', () => {
|
||||
const el = emptied()
|
||||
|
||||
beginComposerComposition(el)
|
||||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
|
||||
})
|
||||
|
||||
it('brings the placeholder back when composition ends with nothing committed', () => {
|
||||
const el = emptied()
|
||||
|
||||
beginComposerComposition(el)
|
||||
normalizeComposerEditorDom(el)
|
||||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/** A directive chip, as `refChipElement` builds it. */
|
||||
function chip(): HTMLSpanElement {
|
||||
const el = document.createElement('span')
|
||||
|
||||
el.contentEditable = 'false'
|
||||
el.dataset.refText = '@folder:`apps/desktop/`'
|
||||
el.append(document.createTextNode('apps/desktop/'))
|
||||
|
||||
return el
|
||||
}
|
||||
|
||||
function caretAt(node: Node, offset: number) {
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(node, offset)
|
||||
range.collapse(true)
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
/** Committing a completion empties the typed token's text node instead of
|
||||
* removing it, and `Range.insertNode` splits the line around the caret — so a
|
||||
* freshly-chipped directive sits between zero-length text nodes. Backspace has
|
||||
* to see past them or the chip can't be deleted at all. */
|
||||
describe('backspace deletes a chip surrounded by Chromium litter', () => {
|
||||
it('deletes the chip when the caret sits in a zero-length text node after it', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(document.createTextNode(''), chip(), document.createTextNode(''))
|
||||
caretAt(el.childNodes[2] as Node, 0)
|
||||
|
||||
expect(deleteChipBeforeCaret(el)).toBe(true)
|
||||
expect(el.querySelector('[data-ref-text]')).toBeNull()
|
||||
})
|
||||
|
||||
it('deletes the chip when the caret is past a zero-length text node at editor level', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(chip(), document.createTextNode(''))
|
||||
caretAt(el, 2)
|
||||
|
||||
expect(deleteChipBeforeCaret(el)).toBe(true)
|
||||
expect(el.querySelector('[data-ref-text]')).toBeNull()
|
||||
})
|
||||
|
||||
it('still swallows the auto-inserted trailing space through that litter', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(chip(), document.createTextNode(''), document.createTextNode(' '))
|
||||
caretAt(el, 2)
|
||||
|
||||
expect(deleteChipBeforeCaret(el)).toBe(true)
|
||||
expect(composerPlainText(el)).toBe('')
|
||||
})
|
||||
|
||||
it('keeps real following text when it deletes the chip', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(chip(), document.createTextNode(''), document.createTextNode(' and this'))
|
||||
caretAt(el, 2)
|
||||
|
||||
expect(deleteChipBeforeCaret(el)).toBe(true)
|
||||
expect(composerPlainText(el)).toBe('and this')
|
||||
})
|
||||
|
||||
it('leaves plain text to the native backspace', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(document.createTextNode('hello'))
|
||||
caretAt(el.firstChild as Node, 5)
|
||||
|
||||
expect(deleteChipBeforeCaret(el)).toBe(false)
|
||||
})
|
||||
|
||||
it('sweeps the litter out of the editor when it normalizes', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(document.createTextNode(''), chip(), document.createTextNode(''))
|
||||
normalizeComposerEditorDom(el)
|
||||
|
||||
expect(Array.from(el.childNodes).map(node => node.nodeName)).toEqual(['SPAN'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
import { ContextMenu } from './context-menu'
|
||||
import { COMPOSER_AREAS, runComposerMiddleware } from './contrib'
|
||||
import { ComposerControls } from './controls'
|
||||
import { ComposerDirectiveActions } from './directive-actions'
|
||||
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
|
||||
import { markActiveComposer } from './focus'
|
||||
import { HelpHint } from './help-hint'
|
||||
|
|
@ -57,7 +58,7 @@ import { ActionBadges } from './micro-actions'
|
|||
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
COMPOSER_PLACEHOLDER_CLASS,
|
||||
beginComposerComposition,
|
||||
composerPlainText,
|
||||
deleteChipBeforeCaret,
|
||||
deleteSelectionInEditor,
|
||||
|
|
@ -947,7 +948,6 @@ export function ChatBar({
|
|||
autoCorrect="off"
|
||||
className={cn(
|
||||
'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
COMPOSER_PLACEHOLDER_CLASS,
|
||||
'**:data-ref-text:cursor-default',
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
|
|
@ -969,8 +969,13 @@ export function ChatBar({
|
|||
// until an unrelated edit forces a sync (#39614).
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
onCompositionStart={event => {
|
||||
composingRef.current = true
|
||||
|
||||
// Input events are skipped for the rest of the composition, so
|
||||
// nothing else would clear the empty marker until it ends — and the
|
||||
// hint would sit behind the preedit text the whole time (#75960).
|
||||
beginComposerComposition(event.currentTarget)
|
||||
}}
|
||||
onDragOver={handleInputDragOver}
|
||||
onDrop={handleInputDrop}
|
||||
|
|
@ -985,6 +990,7 @@ export function ChatBar({
|
|||
spellCheck={false}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<ComposerDirectiveActions editorRef={editorRef} />
|
||||
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
|
||||
so the composer-state binding (text + IME + paste + form-submit hookup)
|
||||
wires up. We render the real input UI ourselves above via the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { memo, useState } from 'react'
|
||||
|
||||
import { composerFloatingPill } from '@/components/chat/composer-dock'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useSessionSlice } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -7,11 +8,9 @@ import { $composerActionsBySession, type ComposerAction } from '@/store/composer
|
|||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
/**
|
||||
* Floating pill — the treatment the thread's jump/approval button uses for a
|
||||
* control that sits over scrolling content: full radius, hairline border, the
|
||||
* shared composer fill behind a blur so thread text never bleeds through.
|
||||
* Sized against the composer's own control height so a row of pills lines up
|
||||
* with the chrome it floats above.
|
||||
* Floating pill — the shared treatment for a control that sits over the
|
||||
* composer (`composerFloatingPill`), plus this strip's own width cap and
|
||||
* disabled state.
|
||||
*
|
||||
* NEVER `pointer-events-none`, not even when disabled. The pop-out drag region
|
||||
* is an `absolute` sibling behind these pills, so a pill that stops taking
|
||||
|
|
@ -19,10 +18,8 @@ import { notifyError } from '@/store/notifications'
|
|||
* becomes a grab handle that floats the composer.
|
||||
*/
|
||||
const PILL = cn(
|
||||
'inline-flex h-(--composer-control-size) max-w-56 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5',
|
||||
'border border-border/65 bg-(--composer-fill) backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
|
||||
'text-xs font-normal text-(--ui-text-secondary) transition-colors',
|
||||
'hover:bg-(--chrome-action-hover) hover:text-foreground',
|
||||
composerFloatingPill,
|
||||
'max-w-56',
|
||||
'disabled:cursor-default disabled:opacity-50 disabled:hover:bg-(--composer-fill)',
|
||||
'focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,28 +21,67 @@ import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
|
|||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
/** Paints `data-placeholder` while the editor is empty.
|
||||
/** Chromium's litter: editing beside a `contenteditable=false` chip splits the
|
||||
* line and leaves zero-length text nodes behind. They render as nothing and
|
||||
* serialize as nothing, so no reader of the editor should count them. */
|
||||
function isEmptyTextNode(node: ChildNode | null): boolean {
|
||||
return node?.nodeType === Node.TEXT_NODE && !node.textContent
|
||||
}
|
||||
|
||||
/** The node before `node`, stepping over that litter. */
|
||||
function meaningfulPreviousSibling(node: ChildNode | null): ChildNode | null {
|
||||
let prev = node?.previousSibling ?? null
|
||||
|
||||
while (isEmptyTextNode(prev)) {
|
||||
prev = prev?.previousSibling ?? null
|
||||
}
|
||||
|
||||
return prev
|
||||
}
|
||||
|
||||
/** The node after `node`, stepping over that litter. */
|
||||
function meaningfulNextSibling(node: ChildNode | null): ChildNode | null {
|
||||
let next = node?.nextSibling ?? null
|
||||
|
||||
while (isEmptyTextNode(next)) {
|
||||
next = next?.nextSibling ?? null
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/** Keep the `data-empty` marker the placeholder paints on in step with the
|
||||
* editor root's contents.
|
||||
*
|
||||
* `:empty` can't be the whole test: a cleared editor keeps a scaffolding <br>
|
||||
* so the contenteditable doesn't collapse, and that break makes `:empty`
|
||||
* false. Nor can CSS infer it on its own — a text node is invisible to
|
||||
* selectors, so `one<br>` and a lone `<br>` are the same shape, and
|
||||
* `:has(> br:only-child)` would paint the placeholder straight over the
|
||||
* user's text. The code that empties the editor is what knows, so it marks it.
|
||||
* `:has(> br:only-child)` would paint the placeholder over the user's text.
|
||||
* The code that empties the editor is what knows, so it marks it.
|
||||
*
|
||||
* @see markEditorEmptiness */
|
||||
export const COMPOSER_PLACEHOLDER_CLASS =
|
||||
'[&:is(:empty,[data-empty])]:before:content-[attr(data-placeholder)] [&:is(:empty,[data-empty])]:before:text-muted-foreground/60'
|
||||
|
||||
/** Keep that marker in step with the editor root's contents. */
|
||||
* Zero-length text nodes don't count as contents. Chromium leaves them behind
|
||||
* whenever an edit lands next to a `contenteditable=false` chip, and counting
|
||||
* them left an editor the user had emptied looking occupied. */
|
||||
export function markEditorEmptiness(editor: HTMLElement) {
|
||||
if (editor.childNodes.length === 0) {
|
||||
if (Array.from(editor.childNodes).every(isEmptyTextNode)) {
|
||||
editor.dataset.empty = ''
|
||||
} else {
|
||||
delete editor.dataset.empty
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the marker as IME composition starts, before any preedit text lands.
|
||||
*
|
||||
* Input events during composition are deliberately skipped (they carry
|
||||
* uncommitted preedit text), so nothing else clears the marker until
|
||||
* `compositionend` — and the hint would otherwise sit behind the hiragana the
|
||||
* user is composing. `normalizeComposerEditorDom` restores it if composition
|
||||
* ends with nothing committed. */
|
||||
export function beginComposerComposition(editor: HTMLElement) {
|
||||
delete editor.dataset.empty
|
||||
}
|
||||
|
||||
/** @see referenceRe — the shared pattern every surface recognises a reference
|
||||
* with. Module-level `/g` regexes carry `lastIndex`, so call sites reset it. */
|
||||
export const REF_RE = referenceRe()
|
||||
|
|
@ -407,7 +446,14 @@ export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment
|
|||
/** Backspace at a collapsed caret immediately after a chip: delete the chip AND
|
||||
* the single trailing space we auto-insert after it, atomically — so removing a
|
||||
* directive never strands an orphaned space (the contenteditable-driven cleanup
|
||||
* was unreliable). Returns whether it ran. */
|
||||
* was unreliable). Returns whether it ran.
|
||||
*
|
||||
* "Immediately after" has to be read through Chromium's litter. Committing a
|
||||
* completion empties the typed token's text node rather than removing it, and
|
||||
* `Range.insertNode` splits around the caret, so the chip routinely sits
|
||||
* between zero-length text nodes. Reading those as content made the caret look
|
||||
* like it was after plain text; the delete declined and Chromium's own
|
||||
* backspace bounced between the leftovers instead of removing the chip. */
|
||||
export function deleteChipBeforeCaret(editor: HTMLElement): boolean {
|
||||
const hit = composerSelectionRange(editor)
|
||||
|
||||
|
|
@ -419,16 +465,20 @@ export function deleteChipBeforeCaret(editor: HTMLElement): boolean {
|
|||
let chip: ChildNode | null = null
|
||||
|
||||
if (startContainer === editor) {
|
||||
chip = startOffset > 0 ? editor.childNodes[startOffset - 1] : null
|
||||
chip = startOffset > 0 ? (editor.childNodes[startOffset - 1] ?? null) : null
|
||||
|
||||
if (isEmptyTextNode(chip)) {
|
||||
chip = meaningfulPreviousSibling(chip)
|
||||
}
|
||||
} else if (startContainer.nodeType === Node.TEXT_NODE && startOffset === 0) {
|
||||
chip = startContainer.previousSibling
|
||||
chip = meaningfulPreviousSibling(startContainer as ChildNode)
|
||||
}
|
||||
|
||||
if (chip?.nodeType !== Node.ELEMENT_NODE || !(chip as HTMLElement).dataset.refText) {
|
||||
return false
|
||||
}
|
||||
|
||||
const after = chip.nextSibling
|
||||
const after = meaningfulNextSibling(chip)
|
||||
chip.remove()
|
||||
|
||||
// Drop the auto-inserted trailing space; keep any real following text.
|
||||
|
|
@ -666,6 +716,14 @@ function isBlankNode(node: ChildNode | null): boolean {
|
|||
* rendering emits (we use text nodes + <br> + chips). Real <br> line breaks
|
||||
* (Shift+Enter, which sit after actual text) are preserved. */
|
||||
export function normalizeComposerEditorDom(editor: HTMLElement) {
|
||||
// Chromium's zero-length text nodes first: every check below reads siblings,
|
||||
// and litter between them makes a chip look like it has text either side.
|
||||
for (const child of Array.from(editor.childNodes)) {
|
||||
if (isEmptyTextNode(child)) {
|
||||
child.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// A trailing block wrapper holding only a break/whitespace is the phantom
|
||||
// "new line" Chromium adds after a chip on backspace — drop it.
|
||||
const tailBlock = editor.lastChild as HTMLElement | null
|
||||
|
|
|
|||
|
|
@ -110,10 +110,12 @@ describe('GatewaySettings', () => {
|
|||
fireEvent.click(screen.getByRole('button', { name: 'Save for next restart' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveConnectionConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
profile: 'work',
|
||||
sshRemoteProfile: ''
|
||||
}))
|
||||
expect(saveConnectionConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profile: 'work',
|
||||
sshRemoteProfile: ''
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export function TerminalFontSetting() {
|
|||
<span className="mr-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{copy.terminalFontPreview}
|
||||
</span>
|
||||
<span> ~/project git:main ❯</span>
|
||||
<span> ~/project git:main ❯</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,13 @@ describe('ModelEditSubmenu reports edits without performing them', () => {
|
|||
|
||||
it('thinking: toggling back on restores the row level, not the hardcoded default', () => {
|
||||
const onSetOptions = vi.fn()
|
||||
renderSubmenu({ defaultEffort: 'high', effort: 'none', fastControl: { kind: 'none' }, onSetOptions, reasoning: true })
|
||||
renderSubmenu({
|
||||
defaultEffort: 'high',
|
||||
effort: 'none',
|
||||
fastControl: { kind: 'none' },
|
||||
onSetOptions,
|
||||
reasoning: true
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import type { FC } from 'react'
|
|||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import type { I18nContextValue } from '@/i18n'
|
||||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { openExternalLink } from '@/lib/external-link'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media'
|
||||
import { useSessionLinkTitle } from '@/lib/session-link-title'
|
||||
|
|
@ -442,7 +444,7 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
|
|||
* it's already a tile/main, otherwise open a stacked tab (never steals main
|
||||
* from under the chat you're reading). Lazy-imports so the composer's rich
|
||||
* editor can pull this module in without booting the profile/REST stack. */
|
||||
function openSessionRef(value: string) {
|
||||
export function openSessionRef(value: string) {
|
||||
const { sessionId } = parseSessionRefValue(value)
|
||||
|
||||
if (!sessionId) {
|
||||
|
|
@ -454,6 +456,33 @@ function openSessionRef(value: string) {
|
|||
void import('@/app/open-session').then(({ openSession }) => openSession(sessionId, () => undefined, 'tab'))
|
||||
}
|
||||
|
||||
/** What activating a directive of a given kind does. The single source of truth
|
||||
* for "you can act on this reference," shared by every surface that renders a
|
||||
* chip: the composer's hover pill (`ComposerDirectiveActions`) and the sent
|
||||
* message's clickable chip below. A kind with no entry is inert everywhere.
|
||||
*
|
||||
* Add a kind here and both surfaces light up — that's the whole point of one
|
||||
* table. `icon`/`label` are for the pill; the transcript chip carries its own
|
||||
* glyph and only reads `run`. */
|
||||
export interface DirectiveAction {
|
||||
icon: string
|
||||
label: (t: I18nContextValue['t']) => string
|
||||
run: (value: string) => void
|
||||
}
|
||||
|
||||
export const DIRECTIVE_ACTIONS: Record<string, DirectiveAction> = {
|
||||
session: {
|
||||
icon: 'link-external',
|
||||
label: t => t.composer.openDirective,
|
||||
run: openSessionRef
|
||||
},
|
||||
url: {
|
||||
icon: 'link-external',
|
||||
label: t => t.composer.openDirective,
|
||||
run: openExternalLink
|
||||
}
|
||||
}
|
||||
|
||||
/** A `@session:<profile>/<id>` reference in the user transcript (directive
|
||||
* segments), rendered as a chip like the other composer refs. Clicking it
|
||||
* opens the session as a tab. */
|
||||
|
|
@ -501,14 +530,18 @@ const SlashChip: FC<{ kind: SlashChipKind; label: string; value: string }> = ({
|
|||
</span>
|
||||
)
|
||||
|
||||
/** Inert by default; `onClick` promotes the chip to a real button (session
|
||||
* refs, which open the session they name). */
|
||||
/** A directive reference in a sent message. A kind with a `DIRECTIVE_ACTIONS`
|
||||
* entry (a url, …) renders as a real button that runs it on click; everything
|
||||
* else is inert text. `onClick` overrides for chips that resolve their target
|
||||
* themselves (session, which needs the async navigator). */
|
||||
const DirectiveChip: FC<{
|
||||
type: string
|
||||
label: string
|
||||
id: string
|
||||
onClick?: () => void
|
||||
}> = ({ type, label, id, onClick }) => {
|
||||
const activate = onClick ?? (DIRECTIVE_ACTIONS[type] ? () => DIRECTIVE_ACTIONS[type]!.run(id) : undefined)
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<DirectiveIcon type={type} />
|
||||
|
|
@ -517,14 +550,14 @@ const DirectiveChip: FC<{
|
|||
)
|
||||
|
||||
const props = {
|
||||
...refAttrs(type, cn('wrap-anywhere', onClick && 'cursor-pointer')),
|
||||
...refAttrs(type, cn('wrap-anywhere', activate && 'cursor-pointer')),
|
||||
'data-directive-id': id,
|
||||
'data-slot': 'aui_directive-chip',
|
||||
title: id
|
||||
}
|
||||
|
||||
return onClick ? (
|
||||
<button {...props} onClick={onClick} type="button">
|
||||
return activate ? (
|
||||
<button {...props} onClick={activate} type="button">
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -12,9 +12,12 @@ vi.mock('@/app/open-session', () => ({
|
|||
openSession: (...args: unknown[]) => openSession(...args)
|
||||
}))
|
||||
|
||||
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
openSession.mockClear()
|
||||
delete desktopWindow.hermesDesktop
|
||||
__resetSessionLinkTitleCache()
|
||||
})
|
||||
|
||||
|
|
@ -41,3 +44,23 @@ describe('session refs open the session', () => {
|
|||
await vi.waitFor(() => expect(openSession).toHaveBeenCalledWith('20260101_abc123', expect.any(Function), 'tab'))
|
||||
})
|
||||
})
|
||||
|
||||
// A url the user sent renders as a chip too, and it opens in the browser — the
|
||||
// same door the composer's hover pill uses, so a link behaves the same before
|
||||
// and after send.
|
||||
describe('url refs open externally', () => {
|
||||
it('opens a url chip in the user transcript', () => {
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop']
|
||||
|
||||
render(<DirectiveContent text="see @url:`https://example.com/docs` when you can" />)
|
||||
|
||||
const chip = screen.getByTitle('https://example.com/docs')
|
||||
|
||||
expect(chip.tagName).toBe('BUTTON')
|
||||
fireEvent.click(chip)
|
||||
|
||||
expect(openExternal).toHaveBeenCalledWith('https://example.com/docs')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
useState
|
||||
} from 'react'
|
||||
|
||||
import { ComposerDirectiveActions } from '@/app/chat/composer/directive-actions'
|
||||
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance'
|
||||
import {
|
||||
type ComposerInsertMode,
|
||||
|
|
@ -35,7 +36,6 @@ import {
|
|||
} from '@/app/chat/composer/inline-refs'
|
||||
import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs'
|
||||
import {
|
||||
COMPOSER_PLACEHOLDER_CLASS,
|
||||
composerPlainText,
|
||||
insertComposerContentsAtCaret,
|
||||
placeCaretEnd,
|
||||
|
|
@ -773,7 +773,6 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
autoCorrect="off"
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
|
||||
COMPOSER_PLACEHOLDER_CLASS,
|
||||
'**:data-ref-text:cursor-default',
|
||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||
)}
|
||||
|
|
@ -795,6 +794,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
spellCheck={false}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<ComposerDirectiveActions editorRef={editorRef} />
|
||||
<ComposerPrimitive.Input
|
||||
asChild
|
||||
className="sr-only"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,23 @@ export const composerPanelCard = cn(
|
|||
composerSurfaceGlass
|
||||
)
|
||||
|
||||
/**
|
||||
* A quiet control floating over composer content — the micro-action pills above
|
||||
* the surface, the Open affordance on a hovered link inside it. Full radius,
|
||||
* hairline border, the composer's own fill behind a blur so the text underneath
|
||||
* never shows through. Sized against the composer's control height so a pill
|
||||
* lines up with the chrome it floats above.
|
||||
*
|
||||
* Skin and size only; the call site owns position, width caps, and disabled
|
||||
* state.
|
||||
*/
|
||||
export const composerFloatingPill = cn(
|
||||
'inline-flex h-(--composer-control-size) shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5',
|
||||
'border border-border/65 bg-(--composer-fill) backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
|
||||
'text-xs font-normal text-(--ui-text-secondary) transition-colors',
|
||||
'hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)
|
||||
|
||||
/**
|
||||
* Shared grid for the chrome-free floating strips that bracket the composer —
|
||||
* the micro-action pills above the surface and the `composer.underside` slot
|
||||
|
|
|
|||
|
|
@ -1660,6 +1660,7 @@ export const ar = defineLocale({
|
|||
newSessionPlaceholders: ['اسأل Hermes عن شيء...', 'اطلب من Hermes تنفيذ مهمة...', 'ابدأ محادثة جديدة...'],
|
||||
followUpPlaceholders: ['اكتب متابعة...', 'أضف توجيها...', 'اسأل سؤالا آخر...'],
|
||||
startVoice: 'بدء الصوت',
|
||||
openDirective: 'فتح',
|
||||
queueMessage: 'إضافة الرسالة للطابور',
|
||||
steer: 'توجيه',
|
||||
stop: 'إيقاف',
|
||||
|
|
|
|||
|
|
@ -1980,6 +1980,7 @@ export const en: Translations = {
|
|||
'Adjust or continue'
|
||||
],
|
||||
startVoice: 'Start voice conversation',
|
||||
openDirective: 'Open',
|
||||
queueMessage: 'Queue message',
|
||||
steer: 'Steer the current run',
|
||||
stop: 'Stop',
|
||||
|
|
|
|||
|
|
@ -1817,6 +1817,7 @@ export const ja = defineLocale({
|
|||
'調整または続行'
|
||||
],
|
||||
startVoice: '音声会話を開始',
|
||||
openDirective: '開く',
|
||||
queueMessage: 'メッセージをキューに入れる',
|
||||
stop: '停止',
|
||||
send: '送信',
|
||||
|
|
|
|||
|
|
@ -1662,6 +1662,7 @@ export interface Translations {
|
|||
newSessionPlaceholders: readonly string[]
|
||||
followUpPlaceholders: readonly string[]
|
||||
startVoice: string
|
||||
openDirective: string
|
||||
queueMessage: string
|
||||
steer: string
|
||||
stop: string
|
||||
|
|
|
|||
|
|
@ -1759,6 +1759,7 @@ export const zhHant = defineLocale({
|
|||
'調整或繼續'
|
||||
],
|
||||
startVoice: '開始語音對話',
|
||||
openDirective: '開啟',
|
||||
queueMessage: '排隊訊息',
|
||||
stop: '停止',
|
||||
send: '傳送',
|
||||
|
|
|
|||
|
|
@ -2173,6 +2173,7 @@ export const zh: Translations = {
|
|||
'调整或继续'
|
||||
],
|
||||
startVoice: '开始语音对话',
|
||||
openDirective: '打开',
|
||||
queueMessage: '排队消息',
|
||||
steer: '引导当前运行',
|
||||
stop: '停止',
|
||||
|
|
|
|||
|
|
@ -78,12 +78,7 @@ import {
|
|||
} from './api'
|
||||
import { BoardSwitcher } from './board-switcher'
|
||||
import { TaskDrawer } from './drawer'
|
||||
import {
|
||||
EMPTY_OVERRIDE,
|
||||
ModelOverrideField,
|
||||
overrideCreateFields,
|
||||
type TaskModelOverride
|
||||
} from './model-override'
|
||||
import { EMPTY_OVERRIDE, ModelOverrideField, overrideCreateFields, type TaskModelOverride } from './model-override'
|
||||
import { OrchestrationPanel } from './orchestration'
|
||||
import { columnMeta, type KanbanBoard, type KanbanTask, type TaskEstimate } from './types'
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -732,6 +732,40 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
Composer placeholder.
|
||||
|
||||
The prompt is a PAINTED HINT, never content. Rendering `::before` inline —
|
||||
the obvious way — puts a box in the contenteditable's text flow, and the
|
||||
caret sits after it: click an empty composer and the insertion point lands
|
||||
at the right edge of the hint rather than the left edge of the field, and a
|
||||
hint that wraps makes the empty composer two lines tall. Taking it out of
|
||||
flow is what makes it a hint: the caret keeps the field's real origin, the
|
||||
box keeps its real height, and no pointer or selection can reach the text.
|
||||
|
||||
`data-empty` carries emptiness because `:empty` can't: a cleared editor
|
||||
keeps a scaffolding <br>. See markEditorEmptiness in rich-editor.ts.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
[data-slot='composer-rich-input'] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-slot='composer-rich-input']:is(:empty, [data-empty])::before {
|
||||
content: attr(data-placeholder);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: inherit;
|
||||
overflow: hidden;
|
||||
color: color-mix(in srgb, var(--muted-foreground) 60%, transparent);
|
||||
/* One line, clipped — the hint states the field's purpose; it never reflows
|
||||
the box it sits in. A narrow composer or a long locale string would
|
||||
otherwise wrap and double the empty composer's height. */
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Primitive-level pointer cursor for every interactive control (buttons,
|
||||
selects, menu items, switches, tabs, summaries). Keeps individual
|
||||
components from having to hardcode `cursor-pointer`; explicit cursor
|
||||
|
|
|
|||
|
|
@ -219,6 +219,12 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
|
|||
# (_is_discord_auto_thread_lane's relay-aware sibling reads these).
|
||||
auto_thread_created=bool(src.get("auto_thread_created", False)),
|
||||
auto_thread_initial_name=src.get("auto_thread_initial_name"),
|
||||
# Discord auto-thread session continuity: the connector stamps the
|
||||
# thread id this channel message's reply WILL be auto-threaded into
|
||||
# (== the message id) so the gateway keys the initiating channel message
|
||||
# and its later in-thread follow-ups to ONE session. See
|
||||
# build_session_key / SessionSource.prospective_thread_id.
|
||||
prospective_thread_id=src.get("prospective_thread_id"),
|
||||
# Authentic upstream-trust signal: this event arrived over the
|
||||
# per-instance-authenticated relay WS, so the connector already resolved
|
||||
# it to this instance's owner-bound author. ``platform`` is the
|
||||
|
|
|
|||
|
|
@ -191,6 +191,18 @@ class SessionSource:
|
|||
auto_thread_created: bool = False
|
||||
auto_thread_initial_name: Optional[str] = None
|
||||
|
||||
# Discord auto-thread session-continuity signal. Set by the connector on an
|
||||
# inbound CHANNEL message (no thread_id yet) that its auto-thread policy WILL
|
||||
# deliver into a newly-created thread. A Discord thread created from a message
|
||||
# reuses that message's id as the thread id, so the connector knows the id
|
||||
# before the thread exists. The gateway keys the session on this so a
|
||||
# channel message and its thread follow-ups share ONE session: the channel
|
||||
# message INITIATES it (keyed on the prospective thread id), and later
|
||||
# messages arriving in that thread (real thread_id == this value) CONTINUE
|
||||
# it. Without this, every channel message collapses into one parent-channel
|
||||
# session and only the first auto-thread ever gets an auto-title/rename.
|
||||
prospective_thread_id: Optional[str] = None
|
||||
|
||||
# Internal, wire-INVISIBLE trust signal: True when this event was delivered
|
||||
# to the gateway over the per-instance-authenticated relay WebSocket (the
|
||||
# Team Gateway connector). The connector authenticates the gateway's socket
|
||||
|
|
@ -268,6 +280,8 @@ class SessionSource:
|
|||
d["auto_thread_created"] = True
|
||||
if self.auto_thread_initial_name:
|
||||
d["auto_thread_initial_name"] = self.auto_thread_initial_name
|
||||
if self.prospective_thread_id:
|
||||
d["prospective_thread_id"] = self.prospective_thread_id
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
|
|
@ -291,6 +305,7 @@ class SessionSource:
|
|||
profile=data.get("profile"),
|
||||
auto_thread_created=bool(data.get("auto_thread_created", False)),
|
||||
auto_thread_initial_name=data.get("auto_thread_initial_name"),
|
||||
prospective_thread_id=data.get("prospective_thread_id"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1111,20 +1126,37 @@ def build_session_key(
|
|||
# single group member gets two isolated per-user sessions when the
|
||||
# bridge reshuffles alias forms.
|
||||
participant_id = canonical_whatsapp_identifier(str(participant_id)) or participant_id
|
||||
key_parts = [ns, platform, source.chat_type]
|
||||
# Discord auto-thread continuity: a channel-initiating message carries no
|
||||
# thread_id yet, but the connector tells us the thread its reply WILL be
|
||||
# auto-threaded into (prospective_thread_id == the message id, which becomes
|
||||
# the thread id). Key the session on that so the initiating channel message
|
||||
# and every follow-up that later arrives IN that thread (real thread_id ==
|
||||
# prospective_thread_id) resolve to the SAME session — "initiate in channel,
|
||||
# continue in thread". A real thread_id always wins when present.
|
||||
#
|
||||
# The follow-up arrives with chat_type="thread" while the initiating message
|
||||
# has chat_type="group"/"channel"; normalize the chat_type slot to "thread"
|
||||
# when keying on a prospective id so the two byte-match. (Real-thread events
|
||||
# already carry chat_type="thread", so this only rewrites the initiating
|
||||
# channel message's slot.)
|
||||
effective_thread_id = source.thread_id or source.prospective_thread_id
|
||||
chat_type_slot = source.chat_type
|
||||
if source.prospective_thread_id and not source.thread_id:
|
||||
chat_type_slot = "thread"
|
||||
key_parts = [ns, platform, chat_type_slot]
|
||||
|
||||
if slack_scope_id:
|
||||
key_parts.append(slack_scope_id)
|
||||
if source.chat_id:
|
||||
key_parts.append(source.chat_id)
|
||||
if source.thread_id:
|
||||
key_parts.append(source.thread_id)
|
||||
if effective_thread_id:
|
||||
key_parts.append(effective_thread_id)
|
||||
|
||||
# In threads, default to shared sessions (all participants see the same
|
||||
# conversation). Per-user isolation only applies when explicitly enabled
|
||||
# via thread_sessions_per_user, or when there is no thread (regular group).
|
||||
isolate_user = group_sessions_per_user
|
||||
if source.thread_id and not thread_sessions_per_user:
|
||||
if effective_thread_id and not thread_sessions_per_user:
|
||||
isolate_user = False
|
||||
|
||||
if isolate_user and participant_id:
|
||||
|
|
|
|||
|
|
@ -750,6 +750,10 @@ def _stage_candidate_venv(
|
|||
logger.warning("candidate dependency sync refused: uv.lock is missing")
|
||||
_remove_tree(candidate, boundary=runtime_root)
|
||||
return None
|
||||
# Locked sync must see project [tool.uv] exclude-newer; --no-config /
|
||||
# UV_NO_CONFIG drops it and uv 0.12+ refuses --locked.
|
||||
sync_env = dict(env)
|
||||
sync_env.pop("UV_NO_CONFIG", None)
|
||||
synced = subprocess.run(
|
||||
[
|
||||
uv_bin,
|
||||
|
|
@ -759,10 +763,9 @@ def _stage_candidate_venv(
|
|||
"--locked",
|
||||
"--python",
|
||||
str(_venv_python(candidate)),
|
||||
"--no-config",
|
||||
],
|
||||
cwd=project_root,
|
||||
env=env,
|
||||
env=sync_env,
|
||||
check=False,
|
||||
)
|
||||
if synced.returncode != 0:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ pins an ``engines.npm`` range, so an npm outside that range aborts every
|
|||
``npm ci`` / ``npm install`` we run inside the checkout::
|
||||
|
||||
npm error code EBADENGINE
|
||||
npm error notsup Required: {"node":">=20.0.0","npm":"<11.10.0 || >=12.0.0"}
|
||||
npm error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"}
|
||||
npm error notsup Actual: {"npm":"10.9.8","node":"v22.23.1"}
|
||||
|
||||
Rather than predicting the failure (which would mean a semver range matcher and
|
||||
|
|
|
|||
|
|
@ -742,6 +742,90 @@ class TestWhatsAppSessionKeyConsistency:
|
|||
assert build_session_key(alice) == build_session_key(bob)
|
||||
|
||||
|
||||
def test_discord_prospective_thread_initiates_and_continues_one_session(self):
|
||||
"""Discord auto-thread continuity: a channel-initiating message (no
|
||||
thread_id, but a connector-supplied prospective_thread_id) and the later
|
||||
follow-ups that arrive IN that thread (real thread_id == the prospective
|
||||
id) must resolve to ONE session — "initiate in channel, continue in
|
||||
thread". This is the fix for every-thread-after-the-first never getting
|
||||
an auto-title/rename (staging 2026-08-02)."""
|
||||
# The channel-initiating message: no thread yet, connector says it will
|
||||
# be threaded into thread id "msg-100" (== the message id).
|
||||
initiating = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="channel-1",
|
||||
chat_type="group",
|
||||
user_id="cthulhu",
|
||||
prospective_thread_id="msg-100",
|
||||
)
|
||||
# A follow-up that actually arrives inside that thread.
|
||||
follow_up = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="channel-1",
|
||||
chat_type="thread",
|
||||
thread_id="msg-100",
|
||||
user_id="cthulhu",
|
||||
)
|
||||
key_init = build_session_key(initiating)
|
||||
key_follow = build_session_key(follow_up)
|
||||
assert key_init.endswith(":msg-100")
|
||||
assert key_init == key_follow
|
||||
|
||||
def test_discord_distinct_prospective_threads_are_distinct_sessions(self):
|
||||
"""Two different channel messages each initiate their OWN thread/session,
|
||||
so each gets its own auto-title/rename (the reported bug: only the first
|
||||
thread per channel was ever named)."""
|
||||
first = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="channel-1",
|
||||
chat_type="group",
|
||||
user_id="cthulhu",
|
||||
prospective_thread_id="msg-100",
|
||||
)
|
||||
second = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="channel-1",
|
||||
chat_type="group",
|
||||
user_id="cthulhu",
|
||||
prospective_thread_id="msg-200",
|
||||
)
|
||||
assert build_session_key(first) != build_session_key(second)
|
||||
assert build_session_key(first).endswith(":msg-100")
|
||||
assert build_session_key(second).endswith(":msg-200")
|
||||
|
||||
def test_real_thread_id_wins_over_prospective(self):
|
||||
"""A real thread_id always takes precedence over prospective_thread_id
|
||||
(they normally match; if both are somehow set, the real one wins)."""
|
||||
source = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="channel-1",
|
||||
chat_type="thread",
|
||||
thread_id="real-thread",
|
||||
prospective_thread_id="ignored",
|
||||
user_id="cthulhu",
|
||||
)
|
||||
assert build_session_key(source).endswith(":real-thread")
|
||||
|
||||
def test_prospective_thread_shares_across_participants(self):
|
||||
"""A prospective-thread session is shared across participants, same as a
|
||||
real thread (thread sessions are not per-user by default)."""
|
||||
alice = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="channel-1",
|
||||
chat_type="group",
|
||||
user_id="alice",
|
||||
prospective_thread_id="msg-100",
|
||||
)
|
||||
bob = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="channel-1",
|
||||
chat_type="group",
|
||||
user_id="bob",
|
||||
prospective_thread_id="msg-100",
|
||||
)
|
||||
assert build_session_key(alice) == build_session_key(bob)
|
||||
|
||||
|
||||
def test_non_thread_group_sessions_still_isolated_per_user(self):
|
||||
"""Regular group messages (no thread_id) remain per-user by default."""
|
||||
alice = SessionSource(
|
||||
|
|
|
|||
|
|
@ -389,6 +389,48 @@ class TestRuntimeRepair:
|
|||
assert not (root / ".hermes-runtime").exists()
|
||||
mock_install.assert_not_called()
|
||||
|
||||
def test_stage_candidate_sync_keeps_uv_project_config(self, tmp_path):
|
||||
from hermes_cli.managed_uv import _stage_candidate_venv
|
||||
|
||||
root = tmp_path / "checkout"
|
||||
root.mkdir()
|
||||
(root / "uv.lock").write_text("# lock\n", encoding="utf-8")
|
||||
generation = root / ".hermes-runtime" / "python" / "gen"
|
||||
python = generation / "bin" / "python"
|
||||
python.parent.mkdir(parents=True)
|
||||
python.write_text("py", encoding="utf-8")
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(argv, **kwargs):
|
||||
calls.append((list(argv), kwargs.get("env")))
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
|
||||
patch(
|
||||
"hermes_cli.managed_uv._smoke_candidate_venv",
|
||||
return_value=(True, "", None),
|
||||
), \
|
||||
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
|
||||
candidate = _stage_candidate_venv(
|
||||
"uv",
|
||||
project_root=root,
|
||||
generation=generation,
|
||||
python=python,
|
||||
)
|
||||
|
||||
assert candidate is not None
|
||||
assert len(calls) == 2
|
||||
venv_argv, venv_env = calls[0]
|
||||
sync_argv, sync_env = calls[1]
|
||||
assert venv_argv[:2] == ["uv", "venv"]
|
||||
assert "--no-config" in venv_argv
|
||||
assert venv_env.get("UV_NO_CONFIG") == "1"
|
||||
assert sync_argv[:2] == ["uv", "sync"]
|
||||
assert "--locked" in sync_argv
|
||||
assert "--no-config" not in sync_argv
|
||||
assert "UV_NO_CONFIG" not in sync_env
|
||||
|
||||
def test_failed_candidate_preserves_live_venv(self, tmp_path):
|
||||
from hermes_cli.managed_uv import (
|
||||
_acquire_repair_lock,
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
},
|
||||
"engines": {
|
||||
"node": ">=20.0",
|
||||
"npm": "<11.10.0 || >=12.0.0"
|
||||
"npm": ">=11.17.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"core-js@3.49.0": true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue