feat(desktop): openExternalFileForIpc opens files via OS handler
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,11 @@ Two auth-flavored corollaries worth naming because they are easy to get wrong:
|
||||
- **A connection test must exercise the leg you'll actually use.** An HTTP
|
||||
status probe passing while the WebSocket/auth leg fails is a false positive
|
||||
that ships as "it said connected but nothing works."
|
||||
- **Cookie-jar partition names contain nothing Electron percent-escapes.** A
|
||||
`persist:` partition becomes a `Partitions/<escaped name>` folder; a folder
|
||||
name with `%3A` (an escaped `:`) gets a cookie store Windows can neither read
|
||||
nor write, so the session silently never persists. `electron/oauth-partition.ts`
|
||||
pins the invariant; renaming a partition signs its users out once — say so.
|
||||
|
||||
## Compatibility without carrying the past forever
|
||||
|
||||
|
||||
+21
-3
@@ -118,9 +118,10 @@ do **not** pass `h-*`, `px-*`, `py-*`, or icon-size overrides.
|
||||
|
||||
**Variants:** `default` (primary), `destructive`, `secondary` (soft fill —
|
||||
the default non-primary look), `outline` (transparent + 1px inset ring, no
|
||||
fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel",
|
||||
"Clear"), `textStrong` (bold underlined inline affordance — "Change",
|
||||
"Open logs").
|
||||
fill/shadow), `ghost`, `floating` (a control loose from any surface — opaque
|
||||
popover fill + `shadow-md`, hover lifts the glyph only), `link`, `text`
|
||||
(boxless quiet inline — "Cancel", "Clear"), `textStrong` (bold underlined
|
||||
inline affordance — "Change", "Open logs").
|
||||
|
||||
**Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons
|
||||
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro`
|
||||
@@ -177,6 +178,14 @@ Notes:
|
||||
`warn`, `destructive`, `outline`, `solid` (primary fill — icon-corner counts).
|
||||
Sizes: `default`, `xs`, `overlay` (titlebar glyph counts).
|
||||
|
||||
## Context-sensitive dialogs
|
||||
|
||||
Sudo password dialogs keep the backdrop unblurred (`DialogContent`'s
|
||||
`blurBackdrop={false}`) and show the complete, selectable command before the
|
||||
password field. Long commands wrap and scroll; missing backend context is
|
||||
explicit, never inferred from another tool row. Other dialogs retain the shared
|
||||
blurred backdrop.
|
||||
|
||||
## Form controls
|
||||
|
||||
- **`controlVariants`** (`src/components/ui/control.ts`) is the shared shape for
|
||||
@@ -188,6 +197,11 @@ Sizes: `default`, `xs`, `overlay` (titlebar glyph counts).
|
||||
(color mode, tool-call display, usage period). Replaces radio piles and
|
||||
pill rows.
|
||||
- **`Switch`** (`size="xs"`) — bare, with `aria-label`. No bordered text wrapper.
|
||||
- **`FanMenu`** (`src/components/ui/fan-menu.tsx`) — one hub control that
|
||||
fans sibling toggles out on hover: `direction` `vertical` | `horizontal`
|
||||
(split around the hub) | `arc`. Discs are `Button` `floating` off /
|
||||
`default` on; tips anchor left by default. Use it where a row of rarely
|
||||
touched toggles is costing input width (the composer's voice controls).
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -271,6 +285,10 @@ so glass and message-bubble transparency do not reveal scrolling text.
|
||||
from the chip to the floating pill; leaving both dismisses it.
|
||||
- A tool result may expose an inline action that opens a preview. It must not
|
||||
open the rail automatically.
|
||||
- Tool rows reserve destructive red for explicit failures. Missing read paths and
|
||||
ambiguous exit-1 results use neutral notices, with details still available.
|
||||
Errors described inside returned data are not tool failures. Expanded failures
|
||||
show the actual explanation; supporting output keeps its normal text color.
|
||||
- Composer status groups start collapsed except todos. Progress updates and queue
|
||||
pause/resume preserve the user's disclosure choice. Error banners meet the
|
||||
stack's top edge without a blank padding strip. File and preview links remain
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { expect, test } from './test'
|
||||
|
||||
// #100406: in a Bot Mode group room a teammate's `@hermes` handoff must give
|
||||
// the primary profile (internal name `default`) its turn, exactly like
|
||||
// `@hermes → @code-farmer` already does. The live roster stamps the primary
|
||||
// row's handle as the bare profile id ("default"), and the mention parser let
|
||||
// that stamped handle shadow the `@hermes` alias — so the room settled with
|
||||
// Hermes never driven. The mock inference server scripts each member's line
|
||||
// from the user's send (`E2E_SAY(<handle>)[…]`), so the assertion is on the
|
||||
// persisted room log: an entry authored by `default` saying "B".
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
type Page = MockBackendFixture['page']
|
||||
|
||||
interface RoomLogEntry {
|
||||
from?: { kind?: string; name?: string }
|
||||
text?: string
|
||||
}
|
||||
|
||||
async function openBots(page: Page): Promise<void> {
|
||||
const tab = page
|
||||
.getByRole('button', { name: 'Bots', exact: true })
|
||||
.or(page.getByRole('tab', { name: 'Bots', exact: true }))
|
||||
.first()
|
||||
|
||||
await tab.click()
|
||||
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
|
||||
}
|
||||
|
||||
async function createAgent(page: Page, name: string, title: string): Promise<void> {
|
||||
await page.getByRole('button', { name: 'New bot or group chat' }).click()
|
||||
await page.getByRole('menuitem', { name: 'New Bot' }).click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'New Bot' })
|
||||
await dialog.getByPlaceholder('inbox-triage').fill(name)
|
||||
await dialog.getByPlaceholder('Inbox Triage').fill(title)
|
||||
await dialog.getByRole('button', { name: 'Create Bot' }).click()
|
||||
await expect(dialog).toBeHidden({ timeout: 30_000 })
|
||||
await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
/** The plugin's persisted room log (`hermes.plugin.hermes-bots.group-chats`). */
|
||||
async function roomLog(page: Page, group: string): Promise<RoomLogEntry[]> {
|
||||
return page.evaluate(name => {
|
||||
const raw = window.localStorage.getItem('hermes.plugin.hermes-bots.group-chats')
|
||||
const rooms = raw ? (JSON.parse(raw) as Record<string, { log?: RoomLogEntry[] }>) : {}
|
||||
|
||||
return rooms[name]?.log ?? []
|
||||
}, group)
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
// Playwright requires an object-destructured fixture argument.
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
test.afterEach(async ({}, info) => {
|
||||
if (!fixture) {
|
||||
return
|
||||
}
|
||||
|
||||
await info.attach('room-log', {
|
||||
body: JSON.stringify(await roomLog(fixture.page, 'Hermes, Code Farmer'), null, 2),
|
||||
contentType: 'application/json'
|
||||
})
|
||||
await info.attach('native-window', { body: await fixture.page.screenshot(), contentType: 'image/png' })
|
||||
await info.attach('runtime-source', {
|
||||
body: JSON.stringify(
|
||||
await fixture.app.evaluate(() => ({
|
||||
cwd: process.cwd(),
|
||||
argv: process.argv,
|
||||
root: process.env.HERMES_DESKTOP_HERMES_ROOT,
|
||||
home: process.env.HERMES_HOME
|
||||
})),
|
||||
null,
|
||||
2
|
||||
),
|
||||
contentType: 'application/json'
|
||||
})
|
||||
await info.attach('desktop-log', {
|
||||
body: readFileSync(join(fixture.sandbox.hermesHome, 'logs/desktop.log')),
|
||||
contentType: 'text/plain'
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('a teammate handing off with @hermes drives the primary profile', async () => {
|
||||
test.setTimeout(420_000)
|
||||
const page = fixture!.page
|
||||
const group = 'Hermes, Code Farmer'
|
||||
|
||||
await openBots(page)
|
||||
await createAgent(page, 'code-farmer', 'Code Farmer')
|
||||
|
||||
await page.getByRole('button', { name: 'New bot or group chat' }).click()
|
||||
await page.getByRole('menuitem', { name: 'New Group Chat' }).click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'New Group Chat' })
|
||||
|
||||
for (const title of ['Hermes', 'Code Farmer']) {
|
||||
await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click()
|
||||
}
|
||||
|
||||
await dialog.getByRole('textbox', { name: 'Group name' }).fill(group)
|
||||
await dialog.getByRole('button', { name: 'Create Group (2)' }).click()
|
||||
|
||||
const composer = page.getByRole('textbox', { name: `Message ${group}` }).filter({ visible: true })
|
||||
await expect(composer).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// Only Code Farmer is addressed by the user. Its scripted reply hands off
|
||||
// to @hermes; Hermes' scripted reply is "B". Neither script token carries
|
||||
// a literal `@`, so the user send itself never mentions Hermes.
|
||||
await composer.fill(
|
||||
'@code-farmer Please reply with one line only. ' +
|
||||
'E2E_SAY(code-farmer)[{at}hermes Please reply with the letter B.] E2E_SAY(hermes)[B]'
|
||||
)
|
||||
await composer.press('Enter')
|
||||
|
||||
// Code Farmer's handoff line lands first (the reverse direction is not in
|
||||
// question); then the room must NOT settle without Hermes' turn.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await roomLog(page, group)).some(e => e.from?.name === 'code-farmer' && /@hermes/.test(e.text || '')),
|
||||
{
|
||||
timeout: 180_000
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await roomLog(page, group)).some(e => e.from?.name === 'default' && (e.text || '').trim() === 'B'),
|
||||
{
|
||||
timeout: 180_000,
|
||||
message: 'the primary profile (default / @hermes) never took its turn after being @mentioned by a teammate'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// And the transcript shows the handoff answered.
|
||||
await expect(page.getByText('B', { exact: true }).filter({ visible: true }).first()).toBeVisible()
|
||||
|
||||
await composer.fill(
|
||||
'@hermes Begin the reverse handoff. E2E_SAY(hermes)[{at}code-farmer Reply with D.] E2E_SAY(code-farmer)[D]'
|
||||
)
|
||||
await composer.press('Enter')
|
||||
await expect
|
||||
.poll(async () => (await roomLog(page, group)).some(e => e.from?.name === 'code-farmer' && e.text?.trim() === 'D'), {
|
||||
timeout: 180_000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
import { MOCK_REPLY } from '../../../tests-js/scripts/mock-server'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { expect, test } from './test'
|
||||
|
||||
const FIRST_REPLY = 'FIRST_REPLY: initial work completed'
|
||||
const FOLLOWUP_REPLY = 'FOLLOWUP_REPLY: subsequent work completed'
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
async function publicLog(page: MockBackendFixture['page']) {
|
||||
return page.evaluate(() => {
|
||||
const rooms = JSON.parse(localStorage.getItem('hermes.plugin.hermes-bots.group-chats') || '{}')
|
||||
|
||||
return (rooms['Programmer, Reviewer']?.log || []).map((entry: any) => ({
|
||||
from: entry.from.name, text: entry.text, thread: entry.thread
|
||||
})) as { from: string; text: string; thread: string }[]
|
||||
})
|
||||
}
|
||||
|
||||
async function openBots(page: MockBackendFixture['page']): Promise<void> {
|
||||
const tab = page.getByRole('button', { name: 'Bots', exact: true }).or(page.getByRole('tab', { name: 'Bots', exact: true })).first()
|
||||
await tab.click()
|
||||
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
|
||||
}
|
||||
|
||||
async function createAgent(page: MockBackendFixture['page'], name: string, title: string): Promise<void> {
|
||||
await page.getByRole('button', { name: 'New bot or group chat' }).click()
|
||||
await page.getByRole('menuitem', { name: 'New Bot' }).click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'New Bot' })
|
||||
await dialog.getByPlaceholder('inbox-triage').fill(name)
|
||||
await dialog.getByPlaceholder('Inbox Triage').fill(title)
|
||||
await dialog.getByRole('button', { name: 'Create Bot' }).click()
|
||||
await expect(dialog).toBeHidden({ timeout: 30_000 })
|
||||
await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function createRoom(page: MockBackendFixture['page']) {
|
||||
await openBots(page)
|
||||
await createAgent(page, 'programmer', 'Programmer')
|
||||
await createAgent(page, 'reviewer', 'Reviewer')
|
||||
|
||||
await page.getByRole('button', { name: 'New bot or group chat' }).click()
|
||||
await page.getByRole('menuitem', { name: 'New Group Chat' }).click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'New Group Chat' })
|
||||
|
||||
for (const title of ['Programmer', 'Reviewer']) {
|
||||
await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click()
|
||||
}
|
||||
|
||||
await dialog.getByRole('textbox', { name: 'Group name' }).fill('Programmer, Reviewer')
|
||||
await dialog.getByRole('button', { name: 'Create Group (2)' }).click()
|
||||
|
||||
const groupTab = page.getByRole('tab', { name: /Programmer, Reviewer Close/ })
|
||||
const groupComposer = page.getByRole('textbox', { name: 'Message Programmer, Reviewer' }).filter({ visible: true })
|
||||
await expect(groupTab).toBeVisible({ timeout: 20_000 })
|
||||
await expect(groupTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(groupComposer).toBeVisible()
|
||||
|
||||
|
||||
return groupComposer
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
fixture = await setupMockBackend({ mockServer: {
|
||||
holdFirstCompletionContaining: 'LANE_A_FIRST',
|
||||
replyForPrompt: prompt => prompt.includes('LANE_A_FOLLOWUP') ? FOLLOWUP_REPLY : prompt.includes('LANE_A_FIRST') ? FIRST_REPLY : MOCK_REPLY
|
||||
} })
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('group follow-up waits for its active member and retains the reply', async () => {
|
||||
test.setTimeout(240_000)
|
||||
const page = fixture!.page
|
||||
const groupComposer = await createRoom(page)
|
||||
await groupComposer.fill('@programmer LANE_A_FIRST')
|
||||
await groupComposer.press('Enter')
|
||||
await fixture!.mock.waitForHeldCompletion()
|
||||
console.log('PRODUCTION CLOCK: first inference held; no second submit before provider release.')
|
||||
await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-held.png' })
|
||||
await page.getByRole('button', { name: 'Reply in thread', exact: true }).click()
|
||||
const replyComposer = page.getByRole('textbox', { name: 'Reply in thread', exact: true })
|
||||
await replyComposer.fill('@programmer LANE_A_FOLLOWUP')
|
||||
await replyComposer.press('Enter')
|
||||
await page.waitForTimeout(3000)
|
||||
const overlapping = fixture!.mock.receivedPrompts.filter(p => p.includes('LANE_A_FOLLOWUP'))
|
||||
console.log('OVERLAPPING', overlapping)
|
||||
fixture!.mock.releaseHeldStream()
|
||||
expect(overlapping).toHaveLength(0)
|
||||
await expect.poll(() => fixture!.mock.receivedPrompts.some(p => p.includes('LANE_A_FOLLOWUP')), { timeout: 60000 }).toBe(true)
|
||||
const delivered = fixture!.mock.receivedPrompts.find(p => p.includes('LANE_A_FOLLOWUP'))!
|
||||
expect(delivered).toContain(FIRST_REPLY)
|
||||
expect(delivered).not.toContain('LANE_A_FIRST')
|
||||
expect(delivered.indexOf('LANE_A_FOLLOWUP')).toBeLessThan(delivered.indexOf(FIRST_REPLY))
|
||||
await expect(page.getByText(FIRST_REPLY, { exact: true }).filter({ visible: true })).toHaveCount(1)
|
||||
await expect(page.getByText(FOLLOWUP_REPLY, { exact: true }).filter({ visible: true })).toHaveCount(1)
|
||||
await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
|
||||
const log = await publicLog(page)
|
||||
expect(log.map(({ from, text }) => [from, text])).toEqual([
|
||||
['You', '@programmer LANE_A_FIRST'], ['You', '@programmer LANE_A_FOLLOWUP'],
|
||||
['programmer', FIRST_REPLY], ['programmer', FOLLOWUP_REPLY]
|
||||
])
|
||||
expect(new Set(log.map(entry => entry.thread)).size).toBe(1)
|
||||
expect(fixture!.mock.receivedPrompts.filter(p => p.includes('LANE_A_FIRST'))).toHaveLength(1)
|
||||
expect(fixture!.mock.receivedPrompts.filter(p => p.includes('LANE_A_FOLLOWUP'))).toHaveLength(1)
|
||||
console.log('PRODUCTION CLOCK: released; exact public log', JSON.stringify(log))
|
||||
await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-followup-after.png' })
|
||||
})
|
||||
|
||||
test('quiet group still harvests a late answer after sixty observation ticks', async () => {
|
||||
test.setTimeout(240_000)
|
||||
const page = fixture!.page
|
||||
const groupComposer = await createRoom(page)
|
||||
|
||||
await groupComposer.fill('@programmer LANE_A_FIRST')
|
||||
await groupComposer.press('Enter')
|
||||
await fixture!.mock.waitForHeldCompletion()
|
||||
// Jump only the deadline clock, not WebSocket heartbeats or reconnect timers.
|
||||
await page.evaluate(() => {
|
||||
const now = Date.now
|
||||
Date.now = () => now() + 21 * 60_000
|
||||
|
||||
const timeout = window.setTimeout.bind(window)
|
||||
|
||||
;(window as any).__harvestTicks = 0
|
||||
window.setTimeout = ((handler: TimerHandler, delay?: number, ...args: any[]) => {
|
||||
if (delay === 5000) {
|
||||
return timeout(() => {
|
||||
(window as any).__harvestTicks++
|
||||
|
||||
if (typeof handler === 'function') { handler(...args) }
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return timeout(handler, delay, ...args)
|
||||
}) as typeof window.setTimeout
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => (window as any).__harvestTicks), { timeout: 60000 }).toBeGreaterThanOrEqual(60)
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
console.log('Harvest ticks before release:', await page.evaluate(() => (window as any).__harvestTicks))
|
||||
console.log('Activity before release:', await page.getByRole('button', { name: /^Activity/ }).textContent())
|
||||
fixture!.mock.releaseHeldStream()
|
||||
await expect(page.getByText(FIRST_REPLY, { exact: true }).filter({ visible: true })).toHaveCount(1, { timeout: 5000 })
|
||||
await page.waitForTimeout(1000)
|
||||
expect((await publicLog(page)).filter(entry => entry.from === 'programmer').map(entry => entry.text)).toEqual([FIRST_REPLY])
|
||||
console.log('ACCELERATED DEADLINE/OBSERVATION CLOCK ONLY: exact late public log', JSON.stringify(await publicLog(page)))
|
||||
await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-late-after.png' })
|
||||
})
|
||||
|
||||
test('a rejected member turn stays visible when the room settles', async () => {
|
||||
test.setTimeout(240_000)
|
||||
const page = fixture!.page
|
||||
await page.evaluate(() => {
|
||||
const send = WebSocket.prototype.send
|
||||
|
||||
WebSocket.prototype.send = function(data) {
|
||||
const frame = JSON.parse(String(data))
|
||||
|
||||
if (frame.method === 'prompt.submit' && JSON.stringify(frame.params).includes('LANE_A_FAILURE')) {
|
||||
(window as any).__rejected = ((window as any).__rejected || 0) + 1
|
||||
const reject = () => this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ jsonrpc: '2.0', id: frame.id, error: { code: 4003, message: 'Controlled member admission refusal' } }) }))
|
||||
|
||||
if ((window as any).__rejected <= 3) { (window as any).__releaseRefusal = reject } else { queueMicrotask(reject) }
|
||||
} else {
|
||||
send.call(this, data)
|
||||
}
|
||||
}
|
||||
})
|
||||
const groupComposer = await createRoom(page)
|
||||
|
||||
await groupComposer.fill('@programmer LANE_A_FAILURE')
|
||||
await groupComposer.press('Enter')
|
||||
await expect.poll(() => page.evaluate(() => (window as any).__rejected), { timeout: 30000 }).toBe(1)
|
||||
await page.getByRole('button', { name: 'Reply in thread', exact: true }).click()
|
||||
const replyComposer = page.getByRole('textbox', { name: 'Reply in thread', exact: true })
|
||||
await replyComposer.fill('@programmer prequeued LANE_A_FAILURE')
|
||||
await replyComposer.press('Enter')
|
||||
await groupComposer.fill('@programmer cross-thread LANE_A_FAILURE')
|
||||
await groupComposer.press('Enter')
|
||||
console.log('PRODUCTION CLOCK / CONTROLLED TRANSPORT: two sends queued before refusal released')
|
||||
await page.evaluate(() => (window as any).__releaseRefusal())
|
||||
await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
|
||||
await page.waitForTimeout(2000)
|
||||
expect(await page.evaluate(() => (window as any).__rejected)).toBe(1)
|
||||
expect((await publicLog(page)).filter(entry => entry.from !== 'You')).toEqual([])
|
||||
await expect(page.getByRole('button', { name: /^Activity/ })).toContainText('Programmer hit an error')
|
||||
await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-error-after.png' })
|
||||
|
||||
// One epoch: A fails, B waits, the user retries A, then B and A fail.
|
||||
// The retry is explicit and after A's failure, unlike the prequeued sends above.
|
||||
await groupComposer.fill('@programmer LANE_A_FAILURE recency')
|
||||
await groupComposer.press('Enter')
|
||||
await expect.poll(() => page.evaluate(() => (window as any).__rejected)).toBe(2)
|
||||
await groupComposer.fill('@reviewer LANE_A_FAILURE recency')
|
||||
await groupComposer.press('Enter')
|
||||
await page.evaluate(() => (window as any).__releaseRefusal())
|
||||
await expect.poll(() => page.evaluate(() => (window as any).__rejected)).toBe(3)
|
||||
await groupComposer.fill('@programmer LANE_A_FAILURE explicit retry')
|
||||
await groupComposer.press('Enter')
|
||||
await page.evaluate(() => (window as any).__releaseRefusal())
|
||||
await expect.poll(() => page.evaluate(() => (window as any).__rejected), { timeout: 30000 }).toBe(4)
|
||||
await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: /^Activity/ })).toContainText('Programmer hit an error')
|
||||
console.log('CONTROLLED REFUSAL: A failed, B failed, A failed again; newest unresolved summary:', await page.getByRole('button', { name: /^Activity/ }).textContent())
|
||||
})
|
||||
|
||||
test('Stop clears a queued follow-up and a direct mention resumes the held member', async () => {
|
||||
test.setTimeout(240_000)
|
||||
const page = fixture!.page
|
||||
const groupComposer = await createRoom(page)
|
||||
await groupComposer.fill('@programmer LANE_A_FIRST')
|
||||
await groupComposer.press('Enter')
|
||||
await fixture!.mock.waitForHeldCompletion()
|
||||
await page.getByRole('button', { name: 'Reply in thread', exact: true }).click()
|
||||
const replyComposer = page.getByRole('textbox', { name: 'Reply in thread', exact: true })
|
||||
await replyComposer.fill('@programmer LANE_A_CANCELLED')
|
||||
await replyComposer.press('Enter')
|
||||
await page.getByRole('button', { name: 'Stop', exact: true }).click()
|
||||
fixture!.mock.releaseHeldStream()
|
||||
await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
|
||||
await page.waitForTimeout(2000)
|
||||
expect(fixture!.mock.receivedPrompts.some(p => p.includes('LANE_A_CANCELLED'))).toBe(false)
|
||||
console.log('Stop: queued inference count = 0; paused status:', await page.getByText(/Paused:/).allTextContents())
|
||||
await groupComposer.fill('@programmer LANE_A_RESUME')
|
||||
await groupComposer.press('Enter')
|
||||
await expect.poll(() => fixture!.mock.receivedPrompts.some(p => p.includes('LANE_A_RESUME')), { timeout: 60000 }).toBe(true)
|
||||
await expect(page.getByText(MOCK_REPLY, { exact: true }).first()).toBeVisible()
|
||||
await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-stop-resume.png' })
|
||||
})
|
||||
@@ -19,6 +19,7 @@ import { afterAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
destroyKeepaliveAgents,
|
||||
downloadAgentFor,
|
||||
htmlResponseError,
|
||||
httpStatusError,
|
||||
isIdempotentMethod,
|
||||
isTransientTransportError,
|
||||
@@ -404,3 +405,38 @@ describe('httpStatusError', () => {
|
||||
expect(httpStatusError(0, 'boom').statusCode).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('htmlResponseError', () => {
|
||||
it('names an auth redirect with its Location for 3xx and keeps the endpoint-missing capability wording for 2xx HTML', () => {
|
||||
const redirected = htmlResponseError(
|
||||
'https://gateway.example.com/api/profiles',
|
||||
302,
|
||||
'https://sso.example.com/login?next=%2Fapi%2Fprofiles'
|
||||
).message
|
||||
|
||||
expect(redirected).toContain('status 302')
|
||||
expect(redirected).toContain('to https://sso.example.com/login?next=%2Fapi%2Fprofiles')
|
||||
expect(redirected).toMatch(/authentication proxy/)
|
||||
expect(redirected).not.toContain('endpoint is likely missing')
|
||||
expect(htmlResponseError('https://gateway.example.com/api/profiles', 307).message).toMatch(/redirected \(status 307\)\. This is usually/)
|
||||
expect(htmlResponseError('https://gateway.example.com/api/missing', 200).message).toContain(
|
||||
'endpoint is likely missing'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not blame credentials when the redirect only fixes the scheme or a trailing slash', () => {
|
||||
for (const [url, location] of [
|
||||
['http://gateway.example.com/api/profiles', 'https://gateway.example.com/api/profiles'],
|
||||
['https://gateway.example.com/api/profiles', '/api/profiles/'],
|
||||
['https://gateway.example.com/hermes/api/health', 'https://gateway.example.com/hermes/api/health/']
|
||||
]) {
|
||||
const message = htmlResponseError(url, 301, location).message
|
||||
|
||||
expect(message).toContain(`to ${location}`)
|
||||
expect(message).toMatch(/scheme or trailing slash/)
|
||||
expect(message).not.toMatch(/authentication proxy/)
|
||||
}
|
||||
|
||||
expect(htmlResponseError('https://gateway.example.com/api/profiles', 302, 'https://gateway.example.com/login').message).toMatch(/authentication proxy/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -194,9 +194,61 @@ function readStatusCode(error: unknown): number {
|
||||
return Number(error && typeof error === 'object' ? (error as { statusCode?: unknown }).statusCode : NaN)
|
||||
}
|
||||
|
||||
/**
|
||||
* Error for a JSON endpoint that did not answer JSON. A 2xx HTML body is the
|
||||
* SPA index.html for an unregistered /api path, and downstream capability
|
||||
* probes (isMissingHealthEndpointError, gateway-rpc) key on the "endpoint is
|
||||
* likely missing" wording. A 3xx (callers never follow redirects, whatever
|
||||
* the body) is the request being bounced elsewhere: an access proxy sending
|
||||
* it to its login page, or a scheme / trailing-slash redirect when the saved
|
||||
* URL is off by that much — so it must neither carry that wording nor blame
|
||||
* the backend, and it names the Location when the server sent one.
|
||||
*/
|
||||
function htmlResponseError(url: string, statusCode: unknown, location?: unknown) {
|
||||
const status = Number(statusCode)
|
||||
|
||||
if (status >= 300 && status < 400) {
|
||||
const target = typeof location === 'string' && location.trim() ? location.trim() : null
|
||||
|
||||
const hint = isSchemeOrSlashRedirect(url, target)
|
||||
? 'The saved gateway URL differs from the server by scheme or trailing slash; update it to match.'
|
||||
: 'This is usually an authentication proxy in front of the gateway; check the saved token and extra gateway headers.'
|
||||
|
||||
return new Error(
|
||||
`Expected JSON from ${url} but the request was redirected (status ${statusCode})${target ? ` to ${target}` : ''}. ${hint}`
|
||||
)
|
||||
}
|
||||
|
||||
return new Error(
|
||||
`Expected JSON from ${url} but got HTML (status ${statusCode}). The endpoint is likely missing on the Hermes backend.`
|
||||
)
|
||||
}
|
||||
|
||||
function isSchemeOrSlashRedirect(requestUrl: string, location: null | string): boolean {
|
||||
if (!location) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const from = new URL(requestUrl)
|
||||
const to = new URL(location, requestUrl)
|
||||
const strip = (value: string) => value.replace(/\/+$/, '')
|
||||
|
||||
return (
|
||||
from.host === to.host &&
|
||||
from.search === to.search &&
|
||||
(from.protocol !== to.protocol || from.pathname !== to.pathname) &&
|
||||
strip(from.pathname) === strip(to.pathname)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
destroyKeepaliveAgents,
|
||||
downloadAgentFor,
|
||||
htmlResponseError,
|
||||
httpStatusError,
|
||||
isIdempotentMethod,
|
||||
isTransientTransportError,
|
||||
|
||||
@@ -26,13 +26,19 @@ import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
|
||||
export function execText(command: string, args: string[], { timeout = 3000 } = {}): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
execFile(command, args, hiddenWindowsChildOptions({ encoding: 'utf8', timeout }), (error, stdout) => {
|
||||
const child = execFile(command, args, hiddenWindowsChildOptions({ encoding: 'utf8', timeout }), (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else if (timeout > 0 && child.killed) {
|
||||
// A SIGTERM handler can exit zero after execFile's timeout fired.
|
||||
reject(new Error(`${command} timed out after ${timeout}ms`))
|
||||
} else {
|
||||
resolve(String(stdout || '').trim())
|
||||
}
|
||||
})
|
||||
|
||||
// These probes are noninteractive; do not leave readers waiting for input.
|
||||
child.stdin?.end()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@ export function createBackendConnectionState<TProcess, TConnection>() {
|
||||
return attempt.generation === generation
|
||||
},
|
||||
|
||||
assertCurrentAttempt(attempt: BackendConnectionAttempt<TConnection>): void {
|
||||
if (attempt.generation !== generation) {
|
||||
throw new Error('Hermes backend start was superseded by a newer connection attempt.')
|
||||
}
|
||||
},
|
||||
|
||||
attachProcess(
|
||||
attempt: BackendConnectionAttempt<TConnection>,
|
||||
nextProcess: TProcess
|
||||
|
||||
@@ -195,9 +195,9 @@ describe('main.ts wiring for #90812', () => {
|
||||
it('routes every registry-scoped REST dispatch (hermes:api) through the single-owner claim', () => {
|
||||
const handlerStart = mainSource.indexOf('async function dispatchRegistryApiRequest(')
|
||||
expect(handlerStart).toBeGreaterThan(-1)
|
||||
const body = mainSource.slice(handlerStart, handlerStart + 900)
|
||||
const body = mainSource.slice(handlerStart, handlerStart + 1_000)
|
||||
|
||||
expect(body).toContain('backendDialClaims.run(backendScopeKey(registryConnectionId, routeProfile)')
|
||||
expect(body).toContain('ensureRegistryBackend(registryConnectionId, routeProfile)')
|
||||
expect(body).toContain("ensureRegistryBackend(registryConnectionId, routeProfile, '', { spawnPriority })")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
@@ -15,6 +16,7 @@ import { test } from 'vitest'
|
||||
import {
|
||||
canImportHermesCli,
|
||||
DEFAULT_PROBE_TIMEOUT_MS,
|
||||
execProbe,
|
||||
hermesRuntimeImportProbe,
|
||||
PROBE_TIMEOUT_MS,
|
||||
resolveProbeTimeoutMs,
|
||||
@@ -29,24 +31,71 @@ import {
|
||||
// (a tiny script we write to disk that exits 0 on --version).
|
||||
const NODE_BIN = process.execPath
|
||||
|
||||
test('canImportHermesCli returns false when path is falsy', () => {
|
||||
assert.equal(canImportHermesCli(''), false)
|
||||
assert.equal(canImportHermesCli(null), false)
|
||||
assert.equal(canImportHermesCli(undefined), false)
|
||||
test('execProbe keeps the parent event loop available to the child', async () => {
|
||||
let unexpectedSocketError: Error | undefined
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
socket.on('error', (error) => {
|
||||
// A successful child exits immediately after reading the sentinel. On
|
||||
// Windows that peer close can surface as ECONNRESET on the server side.
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ECONNRESET') {
|
||||
unexpectedSocketError ??= error
|
||||
}
|
||||
})
|
||||
socket.end('pong')
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address === 'object')
|
||||
|
||||
const childScript = `
|
||||
const net = require('node:net')
|
||||
let reply = ''
|
||||
const socket = net.createConnection(${address.port}, '127.0.0.1')
|
||||
socket.setEncoding('utf8')
|
||||
socket.on('data', (chunk) => { reply += chunk })
|
||||
socket.on('end', () => process.exit(reply === 'pong' ? 0 : 1))
|
||||
socket.on('error', () => process.exit(1))
|
||||
`
|
||||
|
||||
try {
|
||||
await execProbe(NODE_BIN, ['-e', childScript], {
|
||||
stdio: 'ignore',
|
||||
timeout: 5_000,
|
||||
windowsHide: true
|
||||
})
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
})
|
||||
}
|
||||
|
||||
assert.ifError(unexpectedSocketError)
|
||||
})
|
||||
|
||||
test('canImportHermesCli returns false when interpreter cannot run -c', () => {
|
||||
test('canImportHermesCli returns false when path is falsy', async () => {
|
||||
assert.equal(await canImportHermesCli(''), false)
|
||||
assert.equal(await canImportHermesCli(null), false)
|
||||
assert.equal(await canImportHermesCli(undefined), false)
|
||||
})
|
||||
|
||||
test('canImportHermesCli returns false when interpreter cannot run -c', async () => {
|
||||
// node IS an interpreter, but `node -c "import hermes_cli"` is a
|
||||
// SyntaxError -- different exit reason from a real Python's
|
||||
// ModuleNotFoundError, but the predicate is "exit 0 or not" and
|
||||
// both land on "not", which is exactly what we want for the
|
||||
// resolver fall-through.
|
||||
assert.equal(canImportHermesCli(NODE_BIN), false)
|
||||
assert.equal(await canImportHermesCli(NODE_BIN), false)
|
||||
})
|
||||
|
||||
test('canImportHermesCli returns false when binary does not exist', () => {
|
||||
test('canImportHermesCli returns false when binary does not exist', async () => {
|
||||
const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe')
|
||||
assert.equal(canImportHermesCli(ghost), false)
|
||||
assert.equal(await canImportHermesCli(ghost), false)
|
||||
})
|
||||
|
||||
test('hermes runtime import probe checks config dependencies', () => {
|
||||
@@ -68,18 +117,18 @@ test('empty Hermes override is not authoritative', () => {
|
||||
assert.equal(shouldTrustHermesOverride(undefined), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns false when command is falsy', () => {
|
||||
assert.equal(verifyHermesCli(''), false)
|
||||
assert.equal(verifyHermesCli(null), false)
|
||||
assert.equal(verifyHermesCli(undefined), false)
|
||||
test('verifyHermesCli returns false when command is falsy', async () => {
|
||||
assert.equal(await verifyHermesCli(''), false)
|
||||
assert.equal(await verifyHermesCli(null), false)
|
||||
assert.equal(await verifyHermesCli(undefined), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns false when binary does not exist', () => {
|
||||
test('verifyHermesCli returns false when binary does not exist', async () => {
|
||||
const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe')
|
||||
assert.equal(verifyHermesCli(ghost), false)
|
||||
assert.equal(await verifyHermesCli(ghost), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns true when --version exits 0', () => {
|
||||
test('verifyHermesCli returns true when --version exits 0', async () => {
|
||||
// Write a tiny script that exits 0 regardless of args, then invoke
|
||||
// it through node. This stands in for a working hermes binary --
|
||||
// verifyHermesCli only cares about the exit code.
|
||||
@@ -92,7 +141,7 @@ test('verifyHermesCli returns true when --version exits 0', () => {
|
||||
// execFileSync passes ['--version'] as args, which node ignores
|
||||
// gracefully (well, it prints its version and exits 0, which is
|
||||
// perfect -- exit code 0 is the only signal we read).
|
||||
assert.equal(verifyHermesCli(NODE_BIN), true)
|
||||
assert.equal(await verifyHermesCli(NODE_BIN), true)
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(scriptPath)
|
||||
@@ -102,12 +151,12 @@ test('verifyHermesCli returns true when --version exits 0', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('verifyHermesCli swallows timeouts (does not throw)', () => {
|
||||
test('verifyHermesCli swallows timeouts (does not throw)', async () => {
|
||||
// We can't easily provoke a real hang in CI without slowing the
|
||||
// suite, but we CAN confirm that an invocation that DOES throw
|
||||
// (because the binary is missing) returns false rather than
|
||||
// propagating. Same code path the timeout case takes.
|
||||
assert.equal(verifyHermesCli('/definitely/not/a/real/binary/anywhere'), false)
|
||||
assert.equal(await verifyHermesCli('/definitely/not/a/real/binary/anywhere'), false)
|
||||
})
|
||||
|
||||
test('default probe timeout is 15s (not the old 5s death-loop value)', () => {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
* as bootstrap-platform.ts and hardening.ts).
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
/** Default probe budget. 5s false-negativeed healthy Windows cold starts (#61764). */
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 15_000
|
||||
@@ -85,10 +85,10 @@ function isTimeoutError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run execFileSync; on timeout only, retry once before failing.
|
||||
* Run without blocking the event loop; on timeout only, retry once before failing.
|
||||
* Non-timeout failures (ENOENT, non-zero exit) fail immediately.
|
||||
*/
|
||||
function execProbeSync(
|
||||
async function execProbe(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: {
|
||||
@@ -99,16 +99,36 @@ function execProbeSync(
|
||||
shell?: boolean
|
||||
windowsHide?: boolean
|
||||
}
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const run = () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, options)
|
||||
child.once('error', reject)
|
||||
child.once('close', (code, signal) => {
|
||||
// A timed-out probe may handle SIGTERM and exit zero; it is still a timeout.
|
||||
if (code === 0 && !child.killed) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(
|
||||
Object.assign(new Error(`Runtime probe failed: ${command} (${signal || code})`), {
|
||||
code,
|
||||
signal,
|
||||
killed: child.killed
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
execFileSync(command, args, options)
|
||||
await run()
|
||||
} catch (err) {
|
||||
if (!isTimeoutError(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
// One cold-cache / AV miss should not force hermes-setup --update (#61764).
|
||||
execFileSync(command, args, options)
|
||||
await run()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,13 +161,13 @@ function hermesRuntimeImportProbe() {
|
||||
* @param {object} [opts.env] - Additional environment for the probe.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, string> } = {}) {
|
||||
async function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, string> } = {}) {
|
||||
if (!pythonPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
execProbeSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
|
||||
await execProbe(pythonPath, ['-c', hermesRuntimeImportProbe()], {
|
||||
env: { ...process.env, ...(opts.env || {}) },
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
@@ -175,7 +195,7 @@ function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, str
|
||||
* @param {string} hermesCommand - Resolved absolute path to a hermes
|
||||
* executable (or an interpreter+script wrapper).
|
||||
* @param {boolean} [opts.shell] - Whether to run through a shell. For
|
||||
* .cmd/.bat shims on Windows execFileSync needs shell:true to find
|
||||
* .cmd/.bat shims on Windows spawn needs shell:true to find
|
||||
* the cmd interpreter; mirrors the same flag isCommandScript() drives
|
||||
* in resolveHermesBackend.
|
||||
* @returns {boolean}
|
||||
@@ -190,13 +210,13 @@ function shouldTrustHermesOverride(hermesOverride?: string) {
|
||||
return typeof hermesOverride === 'string' && hermesOverride.trim().length > 0
|
||||
}
|
||||
|
||||
function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
|
||||
async function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
|
||||
if (!hermesCommand) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
execProbeSync(hermesCommand, ['--version'], {
|
||||
await execProbe(hermesCommand, ['--version'], {
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
shell: Boolean(opts?.shell),
|
||||
@@ -212,8 +232,9 @@ function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
|
||||
export {
|
||||
canImportHermesCli,
|
||||
DEFAULT_PROBE_TIMEOUT_MS,
|
||||
execProbeSync,
|
||||
execProbe,
|
||||
hermesRuntimeImportProbe,
|
||||
isTimeoutError,
|
||||
PROBE_TIMEOUT_MS,
|
||||
resolveProbeTimeoutMs,
|
||||
shouldTrustHermesOverride,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test, vi } from 'vitest'
|
||||
|
||||
import * as probes from './backend-probes'
|
||||
import { createBackendServeSupportResolver } from './backend-serve-support'
|
||||
|
||||
test('concurrent serve checks share one pending probe and retain its negative result', async () => {
|
||||
let fail!: (error: Error) => void
|
||||
|
||||
const pending = new Promise<void>((_resolve, reject) => {
|
||||
fail = reject
|
||||
})
|
||||
|
||||
const probe = vi.spyOn(probes, 'execProbe').mockReturnValue(pending)
|
||||
const supportsServe = createBackendServeSupportResolver('/unused', () => {})
|
||||
const backend = { command: '/unused/hermes', args: ['serve'] }
|
||||
|
||||
try {
|
||||
const first = supportsServe(backend)
|
||||
const second = supportsServe(backend)
|
||||
const callsWhilePending = probe.mock.calls.length
|
||||
fail(new Error('unsupported'))
|
||||
assert.deepEqual(await Promise.all([first, second]), [false, false])
|
||||
assert.equal(callsWhilePending, 1)
|
||||
assert.equal(await supportsServe(backend), false)
|
||||
assert.equal(probe.mock.calls.length, 1)
|
||||
} finally {
|
||||
probe.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('a probe that fails by timeout is not cached, so the next check re-probes', async () => {
|
||||
const probe = vi
|
||||
.spyOn(probes, 'execProbe')
|
||||
.mockRejectedValueOnce(Object.assign(new Error('timed out'), { killed: true }))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
const supportsServe = createBackendServeSupportResolver('/unused', () => {})
|
||||
const backend = { command: '/unused/hermes', args: ['serve'] }
|
||||
|
||||
try {
|
||||
assert.equal(await supportsServe(backend), false)
|
||||
assert.equal(await supportsServe(backend), true)
|
||||
assert.equal(probe.mock.calls.length, 2)
|
||||
} finally {
|
||||
probe.mockRestore()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { sourceDeclaresServe } from './backend-command'
|
||||
import { execProbe, isTimeoutError, PROBE_TIMEOUT_MS } from './backend-probes'
|
||||
|
||||
interface ServeCandidate {
|
||||
command?: string | null
|
||||
root?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
shell?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
// Does the resolved runtime understand the `serve` subcommand? The desktop
|
||||
// spawns `hermes serve`; runtimes older than serve only have `dashboard`, so
|
||||
// main.ts routes those through the legacy `dashboard --no-open` form instead
|
||||
// of crashing on an unknown subcommand.
|
||||
//
|
||||
// Fast path: read the runtime's own dashboard.py (instant, covers managed
|
||||
// installs, dev checkouts, and the Windows venv). Fallback: probe the CLI once
|
||||
// (covers a bare `hermes` resolved from PATH with no known source root). Result
|
||||
// is cached per resolved runtime so we probe at most once per backend — except
|
||||
// a probe that failed by timeout, which is evicted so the next start re-probes
|
||||
// rather than pinning a cold-AV false negative for the process lifetime.
|
||||
//
|
||||
// One cache per desktop runtime context; source inspection precedes a CLI probe.
|
||||
export function createBackendServeSupportResolver(hermesHome: string, rememberLog: (message: string) => void) {
|
||||
const cache = new Map<string, Promise<boolean>>()
|
||||
|
||||
return async function backendSupportsServe(backend: ServeCandidate): Promise<boolean> {
|
||||
if (!backend || !backend.command) {
|
||||
return true
|
||||
}
|
||||
|
||||
const key = `${backend.command}::${backend.root || ''}`
|
||||
|
||||
if (cache.has(key)) {
|
||||
return cache.get(key)!
|
||||
}
|
||||
|
||||
const pending = (async () => {
|
||||
let supported: boolean | null = null
|
||||
|
||||
if (backend.root) {
|
||||
try {
|
||||
const src = await fs.promises.readFile(
|
||||
path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
supported = sourceDeclaresServe(src)
|
||||
} catch {
|
||||
supported = null // source unreadable — fall through to the probe
|
||||
}
|
||||
}
|
||||
|
||||
if (supported === null) {
|
||||
try {
|
||||
const prefix = backend.args && backend.args[0] === '-m' ? backend.args.slice(0, 2) : []
|
||||
// Same cold-Windows Python-startup class as the runtime probes
|
||||
// (#61764/#72632/#72707): `serve --help` imports at least as much as
|
||||
// `hermes --version` (~10.5s measured cold), and a false negative here
|
||||
// is cached for the process lifetime, silently routing a modern
|
||||
// runtime through the legacy `dashboard` form. Share the probe budget
|
||||
// and its timeout-only retry instead of a thinner local bound.
|
||||
await execProbe(backend.command, [...prefix, 'serve', '--help'], {
|
||||
cwd: backend.root || undefined,
|
||||
env: { ...process.env, HERMES_HOME: hermesHome, ...(backend.env || {}) },
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
// `.cmd`/`.bat` shim backends carry shell: true in their descriptor
|
||||
// (see resolveHermesBackend step 4); execFileSync of a .cmd without
|
||||
// shell throws EINVAL on modern Node, which the catch below would
|
||||
// mis-cache as "serve unsupported" for the process lifetime.
|
||||
shell: Boolean(backend.shell),
|
||||
windowsHide: true
|
||||
})
|
||||
supported = true
|
||||
} catch (err) {
|
||||
// A timeout says nothing about the runtime, only about this machine
|
||||
// right now (cold AV scan, slow disk). Evict so the next call
|
||||
// re-probes; a genuine "unknown subcommand" exit stays cached.
|
||||
if (isTimeoutError(err) && cache.get(key) === pending) {
|
||||
cache.delete(key)
|
||||
}
|
||||
|
||||
supported = false
|
||||
}
|
||||
}
|
||||
|
||||
rememberLog(
|
||||
`[backend] \`serve\` ${supported ? 'supported' : 'unsupported → routing via legacy `dashboard`'} for ${backend.label || key}`
|
||||
)
|
||||
|
||||
return supported
|
||||
})()
|
||||
|
||||
// Publish the promise before yielding; late results never overwrite a newer entry.
|
||||
cache.set(key, pending)
|
||||
|
||||
return pending
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { bootstrapStageLabel, describeBootstrapFailure, missingInstallPartMessage } from './bootstrap-failure-copy'
|
||||
|
||||
test('known stage names get everyday labels; unknown ones are humanized', () => {
|
||||
assert.equal(bootstrapStageLabel('venv'), 'Python environment')
|
||||
assert.equal(bootstrapStageLabel('system-packages'), 'System packages')
|
||||
assert.equal(bootstrapStageLabel('some-new_stage'), 'Some new stage')
|
||||
assert.equal(bootstrapStageLabel(null), null)
|
||||
})
|
||||
|
||||
test('lead sentence is plain and actionable; raw error is confined to the Details line', () => {
|
||||
const raw = "install.ps1 exited 1: spawn ENOENT (stage 'venv')"
|
||||
const message = describeBootstrapFailure('venv', raw)
|
||||
const [lead, ...rest] = message.split('\n')
|
||||
|
||||
assert.match(lead, /'Python environment' step/)
|
||||
assert.match(lead, /Reload and retry/)
|
||||
assert.match(lead, /open the logs/)
|
||||
assert.doesNotMatch(lead, /bootstrap|stage|venv|ENOENT|exited|desktop\.log/)
|
||||
assert.equal(rest.join('\n'), `Details: ${raw}`)
|
||||
})
|
||||
|
||||
test('missing stage and missing error still produce a complete message', () => {
|
||||
const message = describeBootstrapFailure(null, undefined)
|
||||
|
||||
assert.match(message, /^Setting up Hermes stopped before it could finish\./)
|
||||
assert.match(message, /\nDetails: unknown error$/)
|
||||
})
|
||||
|
||||
test('missing-install-part copy names Repair install and keeps the path in Details', () => {
|
||||
const message = missingInstallPartMessage('Python environment missing at /home/me/.hermes/venv')
|
||||
const [lead, details] = message.split('Details: ')
|
||||
|
||||
assert.match(lead, /Repair install/)
|
||||
assert.doesNotMatch(lead, /venv|install\.ps1|\//)
|
||||
assert.equal(details, 'Python environment missing at /home/me/.hermes/venv')
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* User-facing copy for a failed first-run install (bootstrap).
|
||||
*
|
||||
* The install runner reports the manifest stage name that failed (see
|
||||
* electron/bootstrap-runner.ts and the stage manifests in scripts/install.ps1 /
|
||||
* scripts/install.sh) plus the raw error text. This module turns that into an
|
||||
* Error.message the install overlay can show verbatim: a plain lead sentence
|
||||
* naming the step in everyday words and what to do next, with the raw error on
|
||||
* a trailing "Details:" line.
|
||||
*
|
||||
* Pure module: no Electron imports, unit-tested next to it.
|
||||
*/
|
||||
|
||||
/** Manifest stage name -> everyday label. Unknown names fall back to humanizeStageName. */
|
||||
export const BOOTSTRAP_STAGE_LABELS: ReadonlyMap<string, string> = new Map([
|
||||
// scripts/install.ps1 manifest
|
||||
['uv', 'Package installer'],
|
||||
['git', 'Git'],
|
||||
['node', 'Node.js'],
|
||||
['system-packages', 'System packages'],
|
||||
['repository', 'Hermes source code'],
|
||||
['python', 'Python runtime'],
|
||||
['venv', 'Python environment'],
|
||||
['dependencies', 'Python packages'],
|
||||
['node-deps', 'Browser tool packages'],
|
||||
['desktop', 'Desktop app build'],
|
||||
['platform-sdks', 'Platform tools'],
|
||||
['configure', 'Settings'],
|
||||
['config-templates', 'Settings templates'],
|
||||
['path', 'Hermes command'],
|
||||
['gateway', 'Hermes service'],
|
||||
['bootstrap-marker', 'Finishing touches'],
|
||||
// scripts/install.sh manifest (names that differ from the Windows one)
|
||||
['prerequisites', 'System prerequisites'],
|
||||
['python-deps', 'Python packages'],
|
||||
['config', 'Settings'],
|
||||
['setup', 'Settings'],
|
||||
['complete', 'Finishing touches']
|
||||
])
|
||||
|
||||
/** `system-packages` -> `System packages`. */
|
||||
export function humanizeStageName(stage: string): string {
|
||||
const words = stage.replace(/[-_]+/g, ' ').trim()
|
||||
|
||||
return words ? words.charAt(0).toUpperCase() + words.slice(1) : ''
|
||||
}
|
||||
|
||||
export function bootstrapStageLabel(stage: string | null | undefined): string | null {
|
||||
if (!stage) {
|
||||
return null
|
||||
}
|
||||
|
||||
return BOOTSTRAP_STAGE_LABELS.get(stage) ?? humanizeStageName(stage)
|
||||
}
|
||||
|
||||
const BOOTSTRAP_FAILURE_REMEDY =
|
||||
'Common causes: no internet connection, antivirus blocking the installer, or another copy of Hermes running. ' +
|
||||
'Close other Hermes windows and choose Reload and retry; if it fails again, open the logs and send them to support.'
|
||||
|
||||
/**
|
||||
* Build the Error.message for a failed bootstrap. First line is the plain
|
||||
* explanation; the raw error follows on its own "Details:" line.
|
||||
*/
|
||||
export function describeBootstrapFailure(failedStage: string | null | undefined, rawError: unknown): string {
|
||||
const label = bootstrapStageLabel(failedStage)
|
||||
|
||||
const lead = label
|
||||
? `Setting up Hermes stopped during the '${label}' step.`
|
||||
: 'Setting up Hermes stopped before it could finish.'
|
||||
|
||||
const details = typeof rawError === 'string' && rawError.trim() ? rawError.trim() : 'unknown error'
|
||||
|
||||
return `${lead} ${BOOTSTRAP_FAILURE_REMEDY}\nDetails: ${details}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Error.message for an installed Hermes with a piece missing (source tree,
|
||||
* Python environment). The renderer's install overlay offers the Repair install
|
||||
* button ('hermes:bootstrap:repair'), so the copy points there. `whatIsMissing`
|
||||
* names the missing part and its path, e.g. "Python environment missing at /x".
|
||||
*/
|
||||
export function missingInstallPartMessage(whatIsMissing: string): string {
|
||||
return (
|
||||
"Part of Hermes' installation is missing (it may have been deleted or quarantined by antivirus). " +
|
||||
'Choose Repair install below to put it back — your chats and settings are not affected. ' +
|
||||
`Details: ${whatIsMissing}`
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
migrateProfileScopedDesktopPlugins,
|
||||
@@ -101,6 +101,54 @@ describe('reconcileUnifiedDesktopHalves', () => {
|
||||
expect(fs.existsSync(path.join(appRoot, 'media'))).toBe(false)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
'skips a package the app cannot read and still materializes its siblings',
|
||||
async () => {
|
||||
// #111804: one unreadable plugin folder rejected the whole reconcile, so the
|
||||
// desktop-plugins root never resolved and no desktop plugin loaded.
|
||||
const home = makeHome()
|
||||
const appRoot = path.join(home, 'desktop-plugins')
|
||||
const denied = path.join(home, 'plugins', 'denied', 'desktop', 'plugin.js')
|
||||
write(denied, 'x')
|
||||
write(path.join(home, 'plugins', 'good', 'desktop', 'plugin.js'), 'y')
|
||||
fs.chmodSync(denied, 0)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([path.join(appRoot, 'good')])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipping unreadable package denied'))
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
fs.chmodSync(denied, 0o600)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
'keeps (and warns about) the desktop half of a package folder it can no longer read',
|
||||
async () => {
|
||||
// A source the app cannot stat (Windows ACL EPERM, mode-000 folder) is not
|
||||
// an uninstall: the ghost-prune loop must not rm the materialized half.
|
||||
const home = makeHome()
|
||||
const appRoot = path.join(home, 'desktop-plugins')
|
||||
const denied = path.join(home, 'plugins', 'denied')
|
||||
write(path.join(denied, 'desktop', 'plugin.js'), 'x')
|
||||
await reconcileUnifiedDesktopHalves(home, appRoot)
|
||||
expect(fs.existsSync(path.join(appRoot, 'denied'))).toBe(true)
|
||||
fs.chmodSync(denied, 0)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
expect(await reconcileUnifiedDesktopHalves(home, appRoot)).toEqual([])
|
||||
expect(fs.existsSync(path.join(appRoot, 'denied'))).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unreadable package denied'))
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
fs.chmodSync(denied, 0o700)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('stamps the package origin (catalog sidecar, else git remote) so "Install here" can reinstall the agent half', async () => {
|
||||
const home = makeHome()
|
||||
const appRoot = path.join(home, 'desktop-plugins')
|
||||
|
||||
@@ -164,7 +164,11 @@ export async function materializeDesktopHalf(
|
||||
|
||||
try {
|
||||
stat = await fs.promises.stat(entry)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) {
|
||||
console.warn(`[desktop-plugins] cannot read ${packageName}: ${String(error)}`)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -202,6 +206,31 @@ export async function materializeDesktopHalf(
|
||||
return target
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
/** `true` only when the package's `desktop/plugin.js` is genuinely gone. A
|
||||
* source the app is not ALLOWED to stat (Windows ACL EPERM, a mode-000 folder)
|
||||
* is not an uninstall — pruning its root copy would silently drop the pane. */
|
||||
async function sourceGone(name: string, entry: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.stat(entry)
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
if (isMissing(error)) {
|
||||
return true
|
||||
}
|
||||
|
||||
console.warn(`[desktop-plugins] keeping desktop half of unreadable package ${name}: ${String(error)}`)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk every local home's `plugins/` root and materialize each package's
|
||||
* desktop half. First home wins for a name that appears in several profiles
|
||||
* (the default home is first). Also drops root copies whose source package
|
||||
@@ -218,7 +247,18 @@ export async function reconcileUnifiedDesktopHalves(hermesHome: string, appRoot:
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await materializeDesktopHalf(path.join(pluginsRoot, name), appRoot, name)
|
||||
let result: null | string
|
||||
|
||||
try {
|
||||
result = await materializeDesktopHalf(path.join(pluginsRoot, name), appRoot, name)
|
||||
} catch (error) {
|
||||
// One package the app cannot read (Windows ACL EPERM on lstat/copy, a
|
||||
// mode-000 folder) must not reject the whole reconcile — the root would
|
||||
// never resolve and EVERY desktop plugin would silently stop loading.
|
||||
console.warn(`[desktop-plugins] skipping unreadable package ${name}: ${String(error)}`)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (result || fs.existsSync(path.join(pluginsRoot, name, 'desktop', 'plugin.js'))) {
|
||||
seen.add(name)
|
||||
@@ -234,7 +274,7 @@ export async function reconcileUnifiedDesktopHalves(hermesHome: string, appRoot:
|
||||
const dir = path.join(appRoot, name)
|
||||
const marker = await readMarker(dir)
|
||||
|
||||
if (marker && !fs.existsSync(path.join(marker.source, 'plugin.js'))) {
|
||||
if (marker && (await sourceGone(name, path.join(marker.source, 'plugin.js')))) {
|
||||
await fs.promises.rm(dir, { force: true, recursive: true })
|
||||
touched.push(dir)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ test('a first-run bootstrap-needed remote apply connects without ensuring or boo
|
||||
const prepareLocalBackend = vi.fn(async () => bootstrapBackend)
|
||||
|
||||
const pendingConnection = runPrimaryBackendStartup({
|
||||
assertCurrentAttempt: () => {},
|
||||
connectRemote,
|
||||
ensureLocalRuntime,
|
||||
prepareLocalBackend,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const host = vi.hoisted(() => ({ opened: [] as string[], openPathError: '' }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
shell: {
|
||||
openPath: vi.fn(async (target: string) => {
|
||||
host.opened.push(target)
|
||||
|
||||
return host.openPathError
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
import { openExternalFileForIpc } from './fs-open-external'
|
||||
|
||||
async function withTempDir<T>(run: (dir: string) => T | Promise<T>): Promise<T> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-open-external-'))
|
||||
|
||||
try {
|
||||
return await run(dir)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
host.opened.length = 0
|
||||
host.openPathError = ''
|
||||
})
|
||||
|
||||
it('opens a hardened local path via shell.openPath', async () => {
|
||||
await withTempDir(async dir => {
|
||||
const file = path.join(dir, 'report.docx')
|
||||
fs.writeFileSync(file, 'PK\x03\x04')
|
||||
const resolveReadableFileForIpc = vi.fn(async () => ({ resolvedPath: file, stat: fs.statSync(file) }))
|
||||
|
||||
await expect(
|
||||
openExternalFileForIpc({ path: file }, { resolveReadableFileForIpc, stagingRoot: () => dir })
|
||||
).resolves.toEqual({ ok: true, path: file })
|
||||
|
||||
expect(resolveReadableFileForIpc).toHaveBeenCalledWith(file, { purpose: 'Open with system app' })
|
||||
expect(host.opened).toEqual([file])
|
||||
})
|
||||
})
|
||||
|
||||
it('returns ok:false without opening when hardening rejects the path', async () => {
|
||||
const resolveReadableFileForIpc = vi.fn(async () => {
|
||||
throw new Error('Open with system app failed: path points to a directory.')
|
||||
})
|
||||
|
||||
await expect(
|
||||
openExternalFileForIpc({ path: '/tmp' }, { resolveReadableFileForIpc, stagingRoot: () => '/tmp' })
|
||||
).resolves.toEqual({ ok: false, error: 'Open with system app failed: path points to a directory.' })
|
||||
|
||||
expect(host.opened).toEqual([])
|
||||
})
|
||||
|
||||
it('stages remote bytes under external-open then opens the staged copy', async () => {
|
||||
await withTempDir(async dir => {
|
||||
const result = await openExternalFileForIpc(
|
||||
{ data: [0x50, 0x4b], ext: 'pptx', name: '../decks/Q3 结果.pptx' },
|
||||
{ resolveReadableFileForIpc: vi.fn(), stagingRoot: () => dir }
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
|
||||
const staged = host.opened[0] ?? ''
|
||||
|
||||
expect(path.dirname(staged)).toBe(path.join(dir, 'external-open'))
|
||||
expect(path.basename(staged)).toMatch(/^Q3_结果_[0-9a-f]{6}\.pptx$/)
|
||||
expect(fs.statSync(staged).size).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to .bin for an unsafe extension and rejects empty payloads', async () => {
|
||||
await withTempDir(async dir => {
|
||||
const result = await openExternalFileForIpc(
|
||||
{ data: [0x00], ext: '.exe/evil', name: 'weird' },
|
||||
{ resolveReadableFileForIpc: vi.fn(), stagingRoot: () => dir }
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(path.basename(host.opened[0] ?? '')).toMatch(/^weird_[0-9a-f]{6}\.bin$/)
|
||||
})
|
||||
|
||||
await expect(
|
||||
openExternalFileForIpc({} as never, { resolveReadableFileForIpc: vi.fn(), stagingRoot: () => '/tmp' })
|
||||
).resolves.toEqual({ ok: false, error: 'Invalid payload' })
|
||||
})
|
||||
|
||||
it('maps a shell.openPath error string into ok:false', async () => {
|
||||
host.openPathError = 'No default application configured to open this file'
|
||||
|
||||
await withTempDir(async dir => {
|
||||
const file = path.join(dir, 'x.docx')
|
||||
fs.writeFileSync(file, 'PK')
|
||||
|
||||
await expect(
|
||||
openExternalFileForIpc(
|
||||
{ path: file },
|
||||
{
|
||||
resolveReadableFileForIpc: vi.fn(async () => ({ resolvedPath: file, stat: fs.statSync(file) })),
|
||||
stagingRoot: () => dir
|
||||
}
|
||||
)
|
||||
).resolves.toEqual({ error: 'No default application configured to open this file', ok: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
// Open a file with the OS default application. `{ path }` payloads are local
|
||||
// files hardened through resolveReadableFileForIpc; `{ data, ext, name }`
|
||||
// payloads are remote-gateway bytes the renderer already downloaded — staged
|
||||
// under <stagingRoot>/external-open so the OS association can open them.
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { shell } from 'electron'
|
||||
|
||||
type ReadableFileResolver = (
|
||||
filePath: string,
|
||||
options?: { purpose?: string }
|
||||
) => Promise<{ resolvedPath: string; stat: fs.Stats }>
|
||||
|
||||
export interface OpenExternalFileDeps {
|
||||
resolveReadableFileForIpc: ReadableFileResolver
|
||||
stagingRoot: () => string
|
||||
}
|
||||
|
||||
export type OpenExternalFilePayload =
|
||||
| { data: Uint8Array | number[]; ext: string; name: string }
|
||||
| { path: string }
|
||||
|
||||
export type OpenExternalFileResult = { error?: string; ok: boolean; path?: string }
|
||||
|
||||
const SAFE_EXT = /^\.[a-z0-9]{1,8}$/i
|
||||
|
||||
function sanitizeBaseName(value: string) {
|
||||
const base = String(value || '')
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.replace(/\.[^.]+$/, '')
|
||||
|
||||
return (base || '')
|
||||
.replace(/[^\p{L}\p{N}._-]+/gu, '_')
|
||||
.replace(/^[._-]+|[._-]+$/g, '')
|
||||
.slice(0, 80)
|
||||
}
|
||||
|
||||
async function stageExternalFile(
|
||||
deps: OpenExternalFileDeps,
|
||||
data: Uint8Array | number[],
|
||||
rawExt: string,
|
||||
rawName: string
|
||||
): Promise<string> {
|
||||
const normalizedExt = rawExt.startsWith('.') ? rawExt : `.${rawExt}`
|
||||
const ext = SAFE_EXT.test(normalizedExt) ? normalizedExt.toLowerCase() : '.bin'
|
||||
const base = sanitizeBaseName(rawName)
|
||||
const fileName = `${base || 'remote'}_${crypto.randomBytes(3).toString('hex')}${ext}`
|
||||
const dir = path.join(deps.stagingRoot(), 'external-open')
|
||||
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
|
||||
const staged = path.join(dir, fileName)
|
||||
|
||||
await fs.promises.writeFile(staged, Buffer.isBuffer(data) ? data : Buffer.from(data))
|
||||
|
||||
return staged
|
||||
}
|
||||
|
||||
export async function openExternalFileForIpc(
|
||||
payload: OpenExternalFilePayload,
|
||||
deps: OpenExternalFileDeps
|
||||
): Promise<OpenExternalFileResult> {
|
||||
try {
|
||||
let target: string
|
||||
|
||||
if (payload && 'path' in payload && payload.path) {
|
||||
;({ resolvedPath: target } = await deps.resolveReadableFileForIpc(String(payload.path), {
|
||||
purpose: 'Open with system app'
|
||||
}))
|
||||
} else if (payload && 'data' in payload && payload.data) {
|
||||
target = await stageExternalFile(deps, payload.data, String(payload.ext || ''), String(payload.name || ''))
|
||||
} else {
|
||||
return { ok: false, error: 'Invalid payload' }
|
||||
}
|
||||
|
||||
const error = await shell.openPath(target)
|
||||
|
||||
return error ? { ok: false, error } : { ok: true, path: target }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ export const GUEST_ONBOARDING_ENV = 'HERMES_GUEST_ONBOARDING'
|
||||
export const GUEST_ONBOARDING_FLAG = '--guest-onboarding'
|
||||
// Skip the first-run film. A rehearsal aid: the intro is a one-time reveal,
|
||||
// so anyone iterating on the guided chat behind it otherwise sits through it
|
||||
// on every fresh HERMES_HOME. Renderer-only; the backend never sees it.
|
||||
// on every fresh HERMES_HOME. The guide still runs — only the film is
|
||||
// skipped. Renderer-only; the backend never sees it.
|
||||
export const SKIP_INTRO_ENV = 'HERMES_SKIP_INTRO'
|
||||
export const SKIP_INTRO_FLAG = '--skip-intro'
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { LAUNCHER_READY_FD_ENV, notifyLauncherWindowRevealed } from './linux-launcher-ready'
|
||||
|
||||
function createIo() {
|
||||
const writes: Array<[number, string]> = []
|
||||
const closes: number[] = []
|
||||
|
||||
return {
|
||||
closes,
|
||||
io: {
|
||||
closeSync: (fd: number) => {
|
||||
closes.push(fd)
|
||||
},
|
||||
writeSync: (fd: number, data: string) => {
|
||||
writes.push([fd, data])
|
||||
|
||||
return data.length
|
||||
}
|
||||
},
|
||||
writes
|
||||
}
|
||||
}
|
||||
|
||||
test('writes one byte to the launcher fd, closes it and consumes the variable', () => {
|
||||
const env: NodeJS.ProcessEnv = { [LAUNCHER_READY_FD_ENV]: '7' }
|
||||
const { closes, io, writes } = createIo()
|
||||
|
||||
assert.equal(notifyLauncherWindowRevealed(env, io), true)
|
||||
assert.deepEqual(writes, [[7, 'r']])
|
||||
assert.deepEqual(closes, [7])
|
||||
assert.equal(LAUNCHER_READY_FD_ENV in env, false)
|
||||
|
||||
// A second reveal must not touch fd 7 again: the number may now belong to another file.
|
||||
assert.equal(notifyLauncherWindowRevealed(env, io), false)
|
||||
assert.equal(writes.length, 1)
|
||||
})
|
||||
|
||||
test('a launch without the variable, or with a garbage value, is a no-op', () => {
|
||||
const { io, writes } = createIo()
|
||||
|
||||
assert.equal(notifyLauncherWindowRevealed({}, io), false)
|
||||
assert.equal(notifyLauncherWindowRevealed({ [LAUNCHER_READY_FD_ENV]: 'seven' }, io), false)
|
||||
assert.equal(notifyLauncherWindowRevealed({ [LAUNCHER_READY_FD_ENV]: '-1' }, io), false)
|
||||
assert.deepEqual(writes, [])
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Tells the `hermes desktop` launcher that the main window is on screen.
|
||||
*
|
||||
* On Linux an app-grid launch defers writing the app's own `.desktop` entry
|
||||
* until the window has mapped: unpatched gnome-shell (before GNOME MR !4428)
|
||||
* drops a STARTING ShellApp's last reference when its entry changes, and the
|
||||
* next idle GC takes down the whole Wayland session (#111906). The launcher
|
||||
* hands us the write end of a pipe in `HERMES_DESKTOP_READY_FD`; one byte means
|
||||
* "mapped, safe to heal". A launch without the variable is a no-op.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
|
||||
export const LAUNCHER_READY_FD_ENV = 'HERMES_DESKTOP_READY_FD'
|
||||
|
||||
type ReadyFdIo = {
|
||||
writeSync: (fd: number, data: string) => number
|
||||
closeSync: (fd: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the launcher once. The fd is closed after the write and the variable
|
||||
* removed from `env`, so a second reveal (or a child inheriting the env) can
|
||||
* never write into whatever file later reuses that descriptor number.
|
||||
*/
|
||||
export function notifyLauncherWindowRevealed(env: NodeJS.ProcessEnv = process.env, io: ReadyFdIo = fs): boolean {
|
||||
const raw = env[LAUNCHER_READY_FD_ENV]
|
||||
|
||||
if (raw === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
delete env[LAUNCHER_READY_FD_ENV]
|
||||
const fd = Number(raw)
|
||||
|
||||
if (!Number.isInteger(fd) || fd < 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
io.writeSync(fd, 'r')
|
||||
io.closeSync(fd)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@ test('the quit barrier cancels real first-run startup and waits for both local a
|
||||
|
||||
const startup = lifecycle.start(() =>
|
||||
runPrimaryBackendStartup({
|
||||
assertCurrentAttempt: () => {},
|
||||
signal: lifecycle.signal,
|
||||
resolveRemote: async () => null,
|
||||
connectRemote: async () => ({}),
|
||||
|
||||
+260
-186
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,37 @@ test('listen binds a loopback listener and wait resolves with the redirect param
|
||||
await assert.rejects(fetch(`${redirectUri}?code=again&state=st-1`))
|
||||
})
|
||||
|
||||
test('wait relays the RFC 9207 iss parameter from the redirect', async () => {
|
||||
// mcp 2.x rejects an authorization response omitting `iss` when the server
|
||||
// advertised `authorization_response_iss_parameter_supported` (Cloudflare,
|
||||
// Resend), so the listener must not drop it.
|
||||
const { id, redirectUri } = (await invoke('hermes:mcp-oauth:listen')) as { id: string; redirectUri: string }
|
||||
|
||||
const waitPromise = invoke('hermes:mcp-oauth:wait', id, 5000) as Promise<{
|
||||
code: null | string
|
||||
iss: null | string
|
||||
state: null | string
|
||||
}>
|
||||
|
||||
await fetch(`${redirectUri}?code=abc123&state=st-1&iss=${encodeURIComponent('https://mcp.cloudflare.com')}`)
|
||||
|
||||
const result = await waitPromise
|
||||
|
||||
assert.equal(result.code, 'abc123')
|
||||
assert.equal(result.iss, 'https://mcp.cloudflare.com')
|
||||
})
|
||||
|
||||
test('a redirect without iss reports it as null rather than undefined', async () => {
|
||||
// Providers that do not advertise RFC 9207 keep working unchanged.
|
||||
const { id, redirectUri } = (await invoke('hermes:mcp-oauth:listen')) as { id: string; redirectUri: string }
|
||||
|
||||
const waitPromise = invoke('hermes:mcp-oauth:wait', id, 5000) as Promise<{ iss: null | string }>
|
||||
|
||||
await fetch(`${redirectUri}?code=abc123&state=st-1`)
|
||||
|
||||
assert.equal((await waitPromise).iss, null)
|
||||
})
|
||||
|
||||
test('non-callback noise (favicon) does not settle the listener', async () => {
|
||||
const { id, redirectUri } = (await invoke('hermes:mcp-oauth:listen')) as { id: string; redirectUri: string }
|
||||
const origin = redirectUri.replace(/\/callback$/, '')
|
||||
|
||||
@@ -41,6 +41,10 @@ const DONE_HTML =
|
||||
interface CallbackResult {
|
||||
code: null | string
|
||||
error: null | string
|
||||
// RFC 9207 issuer identifier. Cloudflare and Resend advertise
|
||||
// `authorization_response_iss_parameter_supported`, and mcp 2.x rejects an
|
||||
// authorization response that omits it — relay it rather than dropping it.
|
||||
iss: null | string
|
||||
state: null | string
|
||||
}
|
||||
|
||||
@@ -83,7 +87,7 @@ function dispose(id: string) {
|
||||
}
|
||||
|
||||
if (!entry.settled) {
|
||||
settle(id, { code: null, error: 'cancelled', state: null })
|
||||
settle(id, { code: null, error: 'cancelled', iss: null, state: null })
|
||||
}
|
||||
|
||||
pending.delete(id)
|
||||
@@ -112,6 +116,7 @@ export function registerMcpOauthCallbackIpc() {
|
||||
let code: null | string = null
|
||||
let state: null | string = null
|
||||
let error: null | string = null
|
||||
let iss: null | string = null
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, 'http://127.0.0.1')
|
||||
@@ -119,11 +124,12 @@ export function registerMcpOauthCallbackIpc() {
|
||||
code = parsed.searchParams.get('code')
|
||||
state = parsed.searchParams.get('state')
|
||||
error = parsed.searchParams.get('error')
|
||||
iss = parsed.searchParams.get('iss')
|
||||
} catch {
|
||||
error = 'unparseable callback URL'
|
||||
}
|
||||
|
||||
settle(id, { code, error, state })
|
||||
settle(id, { code, error, iss, state })
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -143,7 +149,7 @@ export function registerMcpOauthCallbackIpc() {
|
||||
const entry = pending.get(String(id || ''))
|
||||
|
||||
if (!entry) {
|
||||
return { code: null, error: 'listener not found', state: null }
|
||||
return { code: null, error: 'listener not found', iss: null, state: null }
|
||||
}
|
||||
|
||||
if (entry.result) {
|
||||
@@ -158,7 +164,7 @@ export function registerMcpOauthCallbackIpc() {
|
||||
|
||||
const result = await new Promise<CallbackResult>(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
settle(String(id), { code: null, error: 'timeout waiting for OAuth callback', state: null })
|
||||
settle(String(id), { code: null, error: 'timeout waiting for OAuth callback', iss: null, state: null })
|
||||
}, timeout)
|
||||
|
||||
entry.waiters.push(value => {
|
||||
|
||||
@@ -164,6 +164,44 @@ describe('createMediaProtocolHandler', () => {
|
||||
expect(headers.get('range')).toBe('bytes=0-1023')
|
||||
})
|
||||
|
||||
it('sends the connection extra gateway headers on remote media without clobbering range negotiation', async () => {
|
||||
const deps = dependencies({
|
||||
resolveRemoteConnection: vi.fn(async () => ({
|
||||
authMode: 'token' as const,
|
||||
baseUrl: 'https://gateway.test',
|
||||
headers: { 'CF-Access-Client-Id': 'client-id', Range: 'bytes=9-9' },
|
||||
mode: 'remote' as const,
|
||||
token: 'secret'
|
||||
}))
|
||||
})
|
||||
|
||||
await createMediaProtocolHandler(deps)(
|
||||
request('hermes-media://remote/%2Ftmp%2Fclip.mp4', { Range: 'bytes=0-1023' })
|
||||
)
|
||||
|
||||
const [, headers] = vi.mocked(deps.fetchRemote).mock.calls[0]
|
||||
expect(headers.get('cf-access-client-id')).toBe('client-id')
|
||||
expect(headers.get('range')).toBe('bytes=0-1023')
|
||||
expect(headers.get('x-hermes-session-token')).toBe('secret')
|
||||
})
|
||||
|
||||
it('sends the connection extra gateway headers on OAuth cookie-session remote media', async () => {
|
||||
const deps = dependencies({
|
||||
resolveRemoteConnection: vi.fn(async () => ({
|
||||
authMode: 'oauth' as const,
|
||||
baseUrl: 'https://gateway.test',
|
||||
headers: { 'CF-Access-Client-Id': 'client-id' },
|
||||
mode: 'remote' as const,
|
||||
token: null
|
||||
}))
|
||||
})
|
||||
|
||||
await createMediaProtocolHandler(deps)(request('hermes-media://remote/%2Ftmp%2Fclip.mp4'))
|
||||
|
||||
const [, headers] = vi.mocked(deps.fetchRemoteWithCookies).mock.calls[0]
|
||||
expect(headers.get('cf-access-client-id')).toBe('client-id')
|
||||
})
|
||||
|
||||
it('adds profile scope when one registry backend serves multiple profiles', async () => {
|
||||
const deps = dependencies({
|
||||
resolveRemoteConnection: vi.fn(async () => ({
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface MediaRemoteScope {
|
||||
export interface MediaRemoteConnection {
|
||||
authMode?: 'oauth' | 'token'
|
||||
baseUrl: string
|
||||
headers?: Record<string, string>
|
||||
mode?: 'local' | 'remote'
|
||||
token?: null | string
|
||||
sharedRemote?: boolean
|
||||
@@ -163,6 +164,14 @@ export function createMediaProtocolHandler(dependencies: MediaProtocolDependenci
|
||||
connection.sharedRemote ? target.profile : undefined
|
||||
)
|
||||
|
||||
// The gateway's configured extra headers (access-proxy gates) travel on
|
||||
// every remote request; forwarded range/cache negotiation headers win.
|
||||
for (const [name, value] of Object.entries(connection.headers ?? {})) {
|
||||
if (!headers.has(name)) {
|
||||
headers.set(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
if (connection.authMode === 'oauth') {
|
||||
return await requestWithOauthFallback(connection.baseUrl, {
|
||||
ensureNativeAccessToken: dependencies.ensureRemoteBearer,
|
||||
|
||||
@@ -131,6 +131,24 @@ describe('resolveOauthPartition (#92183 per-connection cookie jars)', () => {
|
||||
expect(resolveOauthPartition('https://gw-a.example.com', { registry: reg })).toBe(got)
|
||||
})
|
||||
|
||||
it('keeps the on-disk path component colon-free and %-free (Windows cookie-store regression)', () => {
|
||||
// Electron escapes ':' in a partition name to '%3A' for the folder name.
|
||||
// A Windows profile folder containing '%3A' gets a cookie store that reads
|
||||
// empty and never persists, so every cookie-auth connection would 401 and
|
||||
// re-prompt for sign-in on each dial. The partition path component must
|
||||
// therefore never need escaping, for ANY connection id.
|
||||
for (const id of ['10-0-0-88-9119', 'we ird/id:€', 'a:b/c%3Ad']) {
|
||||
const reg = registry('local', [remote(id, 'https://gw-a.example.com')])
|
||||
|
||||
const pathComponent = resolveOauthPartition('https://gw-a.example.com/api/auth/ws-ticket', {
|
||||
registry: reg
|
||||
}).slice('persist:'.length)
|
||||
|
||||
expect(pathComponent).not.toContain(':')
|
||||
expect(pathComponent).not.toContain('%')
|
||||
}
|
||||
})
|
||||
|
||||
it('breaks same-URL ties deterministically (identical jar for identical gateway)', () => {
|
||||
const reg = registry('local', [
|
||||
remote('zeta', 'https://gw-a.example.com'),
|
||||
|
||||
@@ -36,7 +36,22 @@
|
||||
|
||||
export const LEGACY_OAUTH_PARTITION = 'persist:hermes-remote-oauth'
|
||||
|
||||
const CONNECTION_PARTITION_PREFIX = `${LEGACY_OAUTH_PARTITION}:conn:`
|
||||
// Colon-free ON PURPOSE: Electron escapes ':' in a partition name to '%3A' in
|
||||
// the on-disk profile folder, and a Windows profile folder whose name contains
|
||||
// '%3A' gets a cookie store the network stack can neither read nor write (a
|
||||
// jar seeded into it reads back zero cookies, `cookies.set()` never reaches
|
||||
// disk, and every cookie-authenticated request 401s as `no_cookie`). A jar
|
||||
// the app cannot see means the dial always looks signed-out and the user is
|
||||
// asked to sign in again on every connect. The whole path component (prefix
|
||||
// AND the sanitized id below) must stay within the characters Electron never
|
||||
// percent-escapes — the invariant test pins "no ':' and no '%'".
|
||||
//
|
||||
// The name used to be `${LEGACY_OAUTH_PARTITION}:conn:<id>` (#92183). That
|
||||
// jar never worked on Windows; on macOS/Linux a non-primary remote signed in
|
||||
// under the old name is re-prompted ONCE after this change (the old
|
||||
// `Partitions/hermes-remote-oauth%3Aconn%3A<id>` folder is left on disk,
|
||||
// inert). One name on every platform beats a per-OS partition scheme.
|
||||
const CONNECTION_PARTITION_PREFIX = `${LEGACY_OAUTH_PARTITION}-conn-`
|
||||
|
||||
export interface PartitionRegistrySnapshot {
|
||||
primary?: unknown
|
||||
|
||||
@@ -43,19 +43,22 @@ test('no report file → no notice', () => {
|
||||
assert.equal(pendingNotice(tmp(), tmp()), null)
|
||||
})
|
||||
|
||||
test('report → one notice naming plugins, date and the CLI command', () => {
|
||||
test('report → one notice naming the plugins and the date, with no config keys or CLI commands', () => {
|
||||
const home = tmp()
|
||||
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(REPORT))
|
||||
const n = pendingNotice(home, tmp())
|
||||
assert.ok(n)
|
||||
assert.equal(n.title, 'Plugins need an update')
|
||||
assert.match(n.message, /2 plugins import module paths that stop working on 2026-09-14/)
|
||||
assert.equal(n.title, 'Some plugins need an update')
|
||||
assert.match(n.message, /stop working on 2026-09-14: alpha, beta\./)
|
||||
assert.match(
|
||||
n.detail,
|
||||
/• alpha — 1 import \(e\.g\. tools\.web_tools\.prefers_gateway → tools\.tool_backend_helpers\.prefers_gateway\)/
|
||||
)
|
||||
assert.match(n.detail, /• beta — 2 imports/)
|
||||
assert.match(n.detail, /hermes plugins compat/)
|
||||
|
||||
for (const text of [n.title, n.message, n.detail]) {
|
||||
assert.doesNotMatch(text, /config\.yaml|hermes plugins compat|allow_deprecated_imports|module path/)
|
||||
}
|
||||
})
|
||||
|
||||
test('dismissal is remembered for the same report and forgotten for a different one', () => {
|
||||
@@ -84,8 +87,9 @@ test('dismissal is remembered for the same report and forgotten for a different
|
||||
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(disabled))
|
||||
const third = pendingNotice(home, userData)
|
||||
assert.ok(third)
|
||||
assert.equal(third.title, 'Some plugins were not loaded')
|
||||
assert.match(third.detail, /allow_deprecated_imports/)
|
||||
assert.equal(third.title, 'Some plugins were turned off')
|
||||
assert.match(third.message, /were turned off: alpha, beta\. Hermes works normally without them\./)
|
||||
assert.doesNotMatch(third.detail, /config\.yaml|hermes plugins compat|allow_deprecated_imports/)
|
||||
assert.notEqual(reportKey(disabled as any), reportKey(REPORT as any))
|
||||
})
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ export function pendingNotice(hermesHome: string, userData: string): PendingNoti
|
||||
}
|
||||
|
||||
const names = Object.keys(report.plugins).sort()
|
||||
const nameList = names.join(', ')
|
||||
|
||||
const list = names
|
||||
.map(n => {
|
||||
@@ -124,15 +125,19 @@ export function pendingNotice(hermesHome: string, userData: string): PendingNoti
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const title = report.in_effect ? 'Some plugins were not loaded' : 'Plugins need an update'
|
||||
// User copy: what happened, what it means for them, what to do. Config keys
|
||||
// and CLI commands stay out; the per-plugin list is for the plugin author.
|
||||
const copy = report.in_effect
|
||||
? {
|
||||
title: 'Some plugins were turned off',
|
||||
message: `These plugins were built for an older Hermes and were turned off: ${nameList}. Hermes works normally without them.`,
|
||||
detail: `Look for an updated version of each plugin or ask its author.\n\n${list}`
|
||||
}
|
||||
: {
|
||||
title: 'Some plugins need an update',
|
||||
message: `These plugins were built for an older Hermes and will stop working on ${report.removal_date}: ${nameList}.`,
|
||||
detail: `Look for an updated version or ask the plugin's author before then.\n\n${list}`
|
||||
}
|
||||
|
||||
const message = report.in_effect
|
||||
? `${names.length} plugin${names.length === 1 ? '' : 's'} import${names.length === 1 ? 's' : ''} module paths that were removed on ${report.removal_date} and ${names.length === 1 ? 'was' : 'were'} not loaded.`
|
||||
: `${names.length} plugin${names.length === 1 ? '' : 's'} import${names.length === 1 ? 's' : ''} module paths that stop working on ${report.removal_date}.`
|
||||
|
||||
const detail = report.in_effect
|
||||
? `${list}\n\nUpdate the plugin(s), or force-load them with plugins.allow_deprecated_imports: true in config.yaml (they will still break once the compatibility layer is removed).\n\nFull list: hermes plugins compat`
|
||||
: `${list}\n\nCheck for plugin updates or notify the author before ${report.removal_date}. After that date these plugins are not loaded.\n\nFull list: hermes plugins compat`
|
||||
|
||||
return { key, title, message, detail }
|
||||
return { key, ...copy }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
BackgroundSlotRetryBackoff,
|
||||
LocalBackendSlotWaitTimeoutError,
|
||||
LocalBackendSpawnCoordinator,
|
||||
releaseLocalBackendSlotAfterExit
|
||||
@@ -24,6 +25,20 @@ const deferred = () => {
|
||||
|
||||
const flush = () => new Promise<void>(resolve => setImmediate(resolve))
|
||||
|
||||
test('background slot failures back off per profile and clear after a later success', () => {
|
||||
const retries = new BackgroundSlotRetryBackoff({ baseDelayMs: 1_000, maxDelayMs: 8_000 })
|
||||
|
||||
assert.equal(retries.canAttempt('over-cap', 0), true)
|
||||
assert.equal(retries.recordFailure('over-cap', 0), 1_000)
|
||||
assert.equal(retries.canAttempt('over-cap', 999), false)
|
||||
assert.equal(retries.canAttempt('over-cap', 1_000), true)
|
||||
assert.equal(retries.recordFailure('over-cap', 1_000), 2_000)
|
||||
assert.equal(retries.canAttempt('other-profile', 1_001), true)
|
||||
|
||||
retries.clear('over-cap')
|
||||
assert.equal(retries.canAttempt('over-cap', 1_001), true)
|
||||
})
|
||||
|
||||
test('100 concurrent local requests never hold more than the configured slots', async () => {
|
||||
const limit = 12
|
||||
const coordinator = new LocalBackendSpawnCoordinator(limit)
|
||||
|
||||
@@ -42,6 +42,58 @@ export function isBackgroundSlotWaitTimeout(error: unknown): boolean {
|
||||
return error instanceof LocalBackendSlotWaitTimeoutError && error.silent
|
||||
}
|
||||
|
||||
/** A retry deferment is expected background control flow, not a start failure. */
|
||||
export class BackgroundSlotRetryDeferredError extends Error {
|
||||
constructor(key: string) {
|
||||
super(`Local backend start for "${key}" is backing off after slot saturation.`)
|
||||
this.name = 'BackgroundSlotRetryDeferredError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isBackgroundSlotRetryDeferred(error: unknown): boolean {
|
||||
return error instanceof BackgroundSlotRetryDeferredError
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-profile cooldown for background hydration after a pool-slot timeout.
|
||||
*
|
||||
* A full local pool is commonly structural (more profiles than the configured
|
||||
* cap), so retrying every roster refresh creates a permanent timeout and log
|
||||
* storm. Foreground opens bypass this guard; a successful slot acquisition
|
||||
* clears it. Keeping this state outside the coordinator preserves its role as
|
||||
* a fair slot allocator rather than teaching it about hydration policy.
|
||||
*/
|
||||
export class BackgroundSlotRetryBackoff {
|
||||
#failures = new Map<string, { nextRetryAt: number; attempts: number }>()
|
||||
readonly #baseDelayMs: number
|
||||
readonly #maxDelayMs: number
|
||||
|
||||
constructor({ baseDelayMs = 60_000, maxDelayMs = 15 * 60_000 } = {}) {
|
||||
if (!Number.isFinite(baseDelayMs) || baseDelayMs < 1 || !Number.isFinite(maxDelayMs) || maxDelayMs < baseDelayMs) {
|
||||
throw new RangeError('Background slot retry delays must be positive and ordered.')
|
||||
}
|
||||
|
||||
this.#baseDelayMs = baseDelayMs
|
||||
this.#maxDelayMs = maxDelayMs
|
||||
}
|
||||
|
||||
canAttempt(key: string, now = Date.now()): boolean {
|
||||
return (this.#failures.get(key)?.nextRetryAt ?? 0) <= now
|
||||
}
|
||||
|
||||
recordFailure(key: string, now = Date.now()): number {
|
||||
const attempts = (this.#failures.get(key)?.attempts ?? 0) + 1
|
||||
const delay = Math.min(this.#baseDelayMs * 2 ** (attempts - 1), this.#maxDelayMs)
|
||||
this.#failures.set(key, { attempts, nextRetryAt: now + delay })
|
||||
|
||||
return delay
|
||||
}
|
||||
|
||||
clear(key: string): void {
|
||||
this.#failures.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseLocalBackendSlotAfterExit(
|
||||
release: ReleaseLocalBackendSlot,
|
||||
waitForExit: () => Promise<void>
|
||||
|
||||
@@ -146,6 +146,57 @@ test('stopAll joins a stop whose pool entry was already evicted', async () => {
|
||||
assert.equal(stopper.hasPending(), false)
|
||||
})
|
||||
|
||||
test('afterStop holds inFlight until extra teardown finishes (process-less SSH)', async () => {
|
||||
const pool = new Map<string, PoolStopEntry>()
|
||||
const events: string[] = []
|
||||
let releaseAfter: (() => void) | undefined
|
||||
const afterGate = new Promise<void>(resolve => {
|
||||
releaseAfter = resolve
|
||||
})
|
||||
const stopper = createPoolStopper({
|
||||
pool,
|
||||
stopChild: () => {
|
||||
events.push('stop')
|
||||
},
|
||||
waitForExit: async () => {
|
||||
events.push('exit')
|
||||
},
|
||||
afterStop: async () => {
|
||||
events.push('after-start')
|
||||
await afterGate
|
||||
events.push('after-done')
|
||||
}
|
||||
})
|
||||
|
||||
pool.set('ssh', { process: null })
|
||||
const stop = stopper.stop('ssh')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
assert.equal(stopper.inFlight('ssh'), stop)
|
||||
assert.deepEqual(events, ['stop', 'exit', 'after-start'])
|
||||
|
||||
let spawned = false
|
||||
const respawn = (async () => {
|
||||
const dying = stopper.inFlight('ssh')
|
||||
|
||||
if (dying) {
|
||||
await dying
|
||||
}
|
||||
|
||||
spawned = true
|
||||
})()
|
||||
|
||||
await Promise.resolve()
|
||||
assert.equal(spawned, false, 'reconnect must wait for SSH teardown, not just child exit')
|
||||
releaseAfter?.()
|
||||
await stop
|
||||
await respawn
|
||||
assert.equal(spawned, true)
|
||||
assert.deepEqual(events, ['stop', 'exit', 'after-start', 'after-done'])
|
||||
assert.equal(stopper.inFlight('ssh'), undefined)
|
||||
})
|
||||
|
||||
test('a respawn can await the in-flight stop before reusing the key', async () => {
|
||||
const { addChild, exitResolvers, stopper } = harness()
|
||||
const child = addChild('selena')
|
||||
|
||||
@@ -33,6 +33,11 @@ export interface PoolStopperDeps {
|
||||
stopChild: (child: unknown) => void
|
||||
/** Bounded wait: resolves when the child exits, escalating to SIGKILL. */
|
||||
waitForExit: (child: unknown) => Promise<void>
|
||||
/**
|
||||
* Extra per-key work that must finish before a replacement may spawn.
|
||||
* Held on the same in-flight promise as child exit (SSH teardown, etc.).
|
||||
*/
|
||||
afterStop?: (key: string) => Promise<void>
|
||||
}
|
||||
|
||||
export interface PoolStopper {
|
||||
@@ -69,6 +74,9 @@ export function createPoolStopper(deps: PoolStopperDeps): PoolStopper {
|
||||
const stopping = (async () => {
|
||||
deps.stopChild(entry.process)
|
||||
await deps.waitForExit(entry.process)
|
||||
if (deps.afterStop) {
|
||||
await deps.afterStop(key)
|
||||
}
|
||||
})().finally(() => {
|
||||
stops.delete(key)
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ test('quit cancels first-run startup without entering the installer', async () =
|
||||
let installed = false
|
||||
|
||||
const startup = runPrimaryBackendStartup({
|
||||
assertCurrentAttempt: () => {},
|
||||
signal: controller.signal,
|
||||
resolveRemote: async () => null,
|
||||
connectRemote: async () => ({}),
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
||||
|
||||
import { test, vi } from 'vitest'
|
||||
|
||||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
import { createFirstRunSetupGate } from './first-run-setup-gate'
|
||||
import {
|
||||
createPrimaryRemoteConnection,
|
||||
@@ -17,6 +18,7 @@ const bootstrapBackend = {
|
||||
|
||||
function startupOptions(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
assertCurrentAttempt: () => {},
|
||||
connectRemote: vi.fn(async remote => ({ baseUrl: remote.baseUrl, mode: 'remote' as const })),
|
||||
ensureLocalRuntime: vi.fn(async backend => ({ ...backend, command: 'hermes' })),
|
||||
prepareLocalBackend: vi.fn(async () => bootstrapBackend),
|
||||
@@ -48,6 +50,30 @@ test('primary remote descriptor preserves a resolved registry connection id', ()
|
||||
assert.equal(connection.isFullscreen, false)
|
||||
})
|
||||
|
||||
test('primary remote descriptor preserves the gateway extra headers for REST calls', () => {
|
||||
// Chat and the Test button carry the headers via the exact-URL WS store, but
|
||||
// fetchJsonForBackend reads descriptor.headers — dropping them here made every
|
||||
// Settings/session-history call hit an access proxy unauthenticated (#112072).
|
||||
const headers = { 'CF-Access-Client-Id': 'client-id', 'CF-Access-Client-Secret': 'client-secret' }
|
||||
|
||||
const connection = createPrimaryRemoteConnection(
|
||||
{
|
||||
authMode: 'token',
|
||||
baseUrl: 'https://gateway.example.com',
|
||||
connectionId: 'gateway',
|
||||
headers,
|
||||
remoteKind: 'url',
|
||||
source: 'settings',
|
||||
token: 'secret',
|
||||
wsUrl: 'wss://gateway.example.com/api/ws?token=secret'
|
||||
},
|
||||
[],
|
||||
{}
|
||||
)
|
||||
|
||||
assert.deepEqual(connection.headers, headers)
|
||||
})
|
||||
|
||||
test('primary remote descriptor preserves the effective SSH dialing identity', () => {
|
||||
const ssh = {
|
||||
effectiveConfigFingerprint: 'effective-config',
|
||||
@@ -161,6 +187,36 @@ test('continue local waits for update exclusion and ensures the prepared runtime
|
||||
assert.deepEqual(options.resolveRemote.mock.calls, [[]])
|
||||
})
|
||||
|
||||
test('invalidating a pending runtime discovery prevents setup and bootstrap', async () => {
|
||||
const state = createBackendConnectionState()
|
||||
const attempt = state.startAttempt()
|
||||
let finish!: (backend: typeof bootstrapBackend) => void
|
||||
let started!: () => void
|
||||
|
||||
const resolving = new Promise<void>(resolve => {
|
||||
started = resolve
|
||||
})
|
||||
|
||||
const options = startupOptions({
|
||||
assertCurrentAttempt: () => state.assertCurrentAttempt(attempt),
|
||||
prepareLocalBackend: async () => {
|
||||
started()
|
||||
|
||||
return new Promise<typeof bootstrapBackend>(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const pending = runPrimaryBackendStartup(options)
|
||||
await resolving
|
||||
state.invalidate()
|
||||
finish(bootstrapBackend)
|
||||
await assert.rejects(pending, /superseded/)
|
||||
assert.equal(options.waitForDecision.mock.calls.length, 0)
|
||||
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
|
||||
})
|
||||
|
||||
test('reset rejects with a typed error and never enters either backend', async () => {
|
||||
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
|
||||
const options = startupOptions({ waitForDecision: gate.wait })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { runBackendStartStep } from './backend-start-cancellation'
|
||||
import type { FirstRunSetupDecision } from './first-run-setup-gate'
|
||||
|
||||
export interface PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection> {
|
||||
assertCurrentAttempt: () => void
|
||||
signal?: AbortSignal
|
||||
connectRemote: (remote: Remote) => Promise<Connection>
|
||||
ensureLocalRuntime: (backend: Backend) => Promise<RuntimeBackend>
|
||||
@@ -18,6 +19,7 @@ interface ResolvedPrimaryRemote {
|
||||
authMode?: 'oauth' | 'token'
|
||||
baseUrl: string
|
||||
connectionId?: string
|
||||
headers?: Record<string, string>
|
||||
remoteHermesVersion?: string
|
||||
remoteHost?: string
|
||||
remoteKind?: 'cloud' | 'ssh' | 'url'
|
||||
@@ -55,6 +57,9 @@ export function createPrimaryRemoteConnection<State extends object>(
|
||||
remoteHermesVersion: remote.remoteHermesVersion,
|
||||
...(remote.connectionId ? { connectionId: remote.connectionId } : {}),
|
||||
...(remote.ssh ? { ssh: remote.ssh } : {}),
|
||||
// fetchJsonForBackend reads descriptor.headers for every REST call; the
|
||||
// WebSocket header store is keyed by exact URL and cannot stand in for it.
|
||||
headers: remote.headers,
|
||||
token: remote.token,
|
||||
wsUrl: remote.wsUrl,
|
||||
logs,
|
||||
@@ -77,6 +82,7 @@ export class FirstRunSetupResetError extends Error {
|
||||
// and local backend resolution happen before the setup gate, and a remote Apply
|
||||
// re-resolves persisted config without ever entering ensureRuntime/bootstrap.
|
||||
export async function runPrimaryBackendStartup<Backend, RuntimeBackend, Remote, Connection>({
|
||||
assertCurrentAttempt,
|
||||
connectRemote,
|
||||
ensureLocalRuntime,
|
||||
prepareLocalBackend,
|
||||
@@ -87,7 +93,13 @@ export async function runPrimaryBackendStartup<Backend, RuntimeBackend, Remote,
|
||||
}: PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection>): Promise<
|
||||
PrimaryBackendStartupResult<RuntimeBackend, Connection>
|
||||
> {
|
||||
const step = <T>(run: () => T | Promise<T>) => runBackendStartStep(signal, run)
|
||||
const step = async <T>(run: () => T | Promise<T>) => {
|
||||
const result = await runBackendStartStep(signal, run)
|
||||
assertCurrentAttempt()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const savedRemote = await step(resolveRemote)
|
||||
|
||||
if (savedRemote) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
openForward,
|
||||
ownershipDirectory,
|
||||
pidIsOurDashboard,
|
||||
probeHermesVersion,
|
||||
probeRemotePlatform,
|
||||
PROTOCOL_VERSION,
|
||||
readLockfile,
|
||||
@@ -476,7 +477,9 @@ test('connect() fails closed on lockfile schema/ownership skew: skips reap, touc
|
||||
`${label}: connect must refuse with remote-lockfile-skew`
|
||||
)
|
||||
assert.ok(
|
||||
!ssh.calls.some(c => /(^|[^-\d])kill -?9? ?\d/.test(c) && !/kill -0/.test(c)),
|
||||
// Any signal, a literal pid: the probe watchdog's `kill -9 $__htp`
|
||||
// targets its own child, not a lockfile pid.
|
||||
!ssh.calls.some(c => /(^|[^-\d])kill(?: -\w+)? \d/.test(c) && !/kill -0/.test(c)),
|
||||
`${label}: must not kill any pid`
|
||||
)
|
||||
assert.ok(!ssh.calls.some(c => /rm -f/.test(c)), `${label}: must not remove any remote file`)
|
||||
@@ -1810,6 +1813,69 @@ test('remote SSH ownership capability requires both secure bootstrap flags', asy
|
||||
assert.equal(await remoteSupportsSshOwnership(unsupported, '/x/hermes'), false)
|
||||
})
|
||||
|
||||
test.skipIf(process.platform === 'win32')('capability probe survives a zsh login shell on the remote (#111949)', async t => {
|
||||
// sshd runs the remote command under the account's LOGIN shell. A bare
|
||||
// `set -m` is fatal in a non-interactive zsh, so the watchdog-wrapped probe
|
||||
// used to return nothing and a current remote was reported as unsupported.
|
||||
const zsh = await exec('command -v zsh || true').then(r => r.stdout.trim())
|
||||
|
||||
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
|
||||
// skip, not a pass, or a wrapper regression stays green unnoticed.
|
||||
if (!zsh) {
|
||||
t.skip('zsh not installed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), 'hermes-zsh-probe-'))
|
||||
|
||||
try {
|
||||
const hermes = path.join(dir, 'hermes')
|
||||
await writeFile(hermes, '#!/bin/sh\necho "--ssh-session-token-file --ssh-owner-nonce"\n', { mode: 0o700 })
|
||||
|
||||
const ssh = { exec: async (command: string) => (await exec(command, { shell: zsh })).stdout }
|
||||
|
||||
assert.equal(await remoteSupportsSshOwnership(ssh, hermes), true)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('probes run under the remote watchdog so a hung CLI cannot orphan (#110478)', async () => {
|
||||
let versionProbe = ''
|
||||
|
||||
const versionSsh = fakeSsh([
|
||||
[
|
||||
/--version/,
|
||||
(cmd: string) => {
|
||||
versionProbe = cmd
|
||||
|
||||
return 'Hermes Agent v0.18.2 (abc123)\n'
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
assert.equal(await probeHermesVersion(versionSsh, '/x/hermes'), 'Hermes Agent v0.18.2 (abc123)')
|
||||
assert.ok(versionProbe.includes('kill -9'), 'version probe wrapped in the remote watchdog')
|
||||
|
||||
let helpProbe = ''
|
||||
|
||||
const helpSsh = fakeSsh([
|
||||
[
|
||||
/serve --help/,
|
||||
(cmd: string) => {
|
||||
helpProbe = cmd
|
||||
|
||||
return 'YES\n'
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
assert.equal(await remoteSupportsSshOwnership(helpSsh, '/x/hermes'), true)
|
||||
assert.ok(helpProbe.includes('kill -9'), 'ownership probe wrapped in the remote watchdog')
|
||||
assert.ok(/\$\(.*\(.*serve --help.*\) <\/dev\/null &/.test(helpProbe), 'watchdog nested around the inner serve --help')
|
||||
})
|
||||
|
||||
test('cleanupStale escalates to SIGKILL when the backend survives the graceful wait (#91668 quit-during-active-turn)', async () => {
|
||||
// A serve mid-turn (in-flight LLM call, live MCP children) can ride out
|
||||
// SIGTERM well past the 5s graceful wait. Before-quit races the whole
|
||||
@@ -1921,3 +1987,78 @@ test.skipIf(process.platform === 'win32')(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// The liveness and ownership probes answer over the same SSH channel that is
|
||||
// often mid-teardown right after the served token resolved. An exec that
|
||||
// returns neither sentinel is indeterminate (#111810): read as DEAD it tore
|
||||
// down a live backend; read as FOREIGN it skipped the reap while removing the
|
||||
// lockfile — one orphaned `serve --isolated` per failed attempt.
|
||||
test('connect() does not declare a live dashboard dead when the liveness probe answers nothing once', async () => {
|
||||
let liveness = 0
|
||||
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, ''],
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''],
|
||||
[/printf '%s\\n'/, ''],
|
||||
[/setsid/, '777\n'],
|
||||
[(cmd: string) => /kill -0 777/.test(cmd) && !cmd.includes('while'), () => (liveness++ === 0 ? '' : 'ALIVE\n')],
|
||||
[/cat .*\.log/, 'HERMES_DASHBOARD_READY port=51999\n']
|
||||
])
|
||||
|
||||
const result = await connect(connectDeps(ssh, { platform: { os: 'Linux', arch: 'x86_64' } }))
|
||||
|
||||
assert.equal(result.reused, false)
|
||||
assert.equal(result.pid, 777)
|
||||
assert.ok(!ssh.calls.some(c => /(^|[^-\d])kill(?: -\w+)? 777\b/.test(c) && !/kill -0/.test(c)), 'must not reap a live backend')
|
||||
})
|
||||
|
||||
test('cleanupStale reaps after one lost ownership answer and keeps the lockfile when none ever settles', async () => {
|
||||
let ownership = 0
|
||||
|
||||
const flaky = fakeSsh([
|
||||
[/print\("OWNED"/, () => (ownership++ === 0 ? '' : 'OWNED\n')],
|
||||
[/kill 777 &&/, 'TERMINATED\n']
|
||||
])
|
||||
|
||||
await cleanupStale(flaky, OWNERSHIP_ID, ownedLock({ pid: 777 }))
|
||||
assert.ok(flaky.calls.some(c => /kill 777 &&/.test(c)), 'must reap the owned backend')
|
||||
assert.ok(flaky.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)))
|
||||
|
||||
const silent = fakeSsh([[/print\("OWNED"/, '']])
|
||||
|
||||
await assert.rejects(
|
||||
cleanupStale(silent, OWNERSHIP_ID, ownedLock({ pid: 777 })),
|
||||
(error: any) => error.kind === 'transient-transport-error'
|
||||
)
|
||||
|
||||
assert.ok(!silent.calls.some(c => /(^|[^-\d])kill(?: -\w+)? 777\b/.test(c) && !/kill -0/.test(c)), 'must not kill unproven')
|
||||
assert.ok(!silent.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)), 'record must survive for the next connect to reap')
|
||||
})
|
||||
|
||||
test('connect() post-spawn cleanup that cannot prove ownership keeps the original boot error', async () => {
|
||||
const boot: any = new Error('dashboard never answered')
|
||||
boot.kind = 'boot-failed'
|
||||
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, ''],
|
||||
[/grep -q ssh-session-token-file/, 'YES\n'],
|
||||
[/python3 -c/, ''],
|
||||
[/printf '%s\\n'/, ''],
|
||||
[/setsid/, '777\n'],
|
||||
[/kill -0 777/, 'ALIVE\n'],
|
||||
[/cat .*\.log/, 'HERMES_DASHBOARD_READY port=51999\n'],
|
||||
[/print\("OWNED"/, '']
|
||||
])
|
||||
|
||||
await assert.rejects(
|
||||
connect(connectDeps(ssh, { platform: { os: 'Linux', arch: 'x86_64' }, waitForHermes: async () => { throw boot } })),
|
||||
(error: any) => error === boot && error.cleanupCause?.kind === 'transient-transport-error'
|
||||
)
|
||||
|
||||
assert.ok(!ssh.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)), 'record must survive for the next connect to reap')
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ import crypto from 'node:crypto'
|
||||
|
||||
import { READY_IN_MERGED_OUTPUT_RE } from './backend-ready'
|
||||
import { parseRemoteProfileListing } from './connection-registry'
|
||||
import { assertBootstrapNotSuperseded } from './ssh-connection'
|
||||
import { assertBootstrapNotSuperseded, withRemoteTimeout } from './ssh-connection'
|
||||
|
||||
const LOCKFILE_SCHEMA_VERSION = 2
|
||||
// Bumped when the desktop<->dashboard reuse contract changes in a way that makes
|
||||
@@ -250,7 +250,9 @@ async function locateHermes(ssh, remoteHermesPath) {
|
||||
// connection uses, so a stale/unexpected install is visible.
|
||||
async function probeHermesVersion(ssh, hermesPath) {
|
||||
try {
|
||||
const out = (await ssh.exec(`${expandRemotePath(hermesPath)} --version 2>&1`)).trim()
|
||||
// Watchdogged: a hung remote CLI must die remotely instead of orphaning
|
||||
// when the local ssh child is SIGKILLed (#110478).
|
||||
const out = (await ssh.exec(withRemoteTimeout(`${expandRemotePath(hermesPath)} --version 2>&1`))).trim()
|
||||
|
||||
return (out.split('\n')[0] || '').trim()
|
||||
} catch {
|
||||
@@ -514,21 +516,58 @@ async function removeLockfile(ssh, ownershipId) {
|
||||
}
|
||||
}
|
||||
|
||||
const PROBE_VERDICT_ATTEMPTS = 3
|
||||
const PROBE_VERDICT_RETRY_MS = 500
|
||||
|
||||
// Liveness and ownership probes print exactly one of two sentinels. An exec
|
||||
// that resolves with neither — the channel died before the remote shell ran,
|
||||
// which is exactly the state of an SSH session mid-teardown right after the
|
||||
// served token was resolved — is indeterminate, not the negative verdict:
|
||||
// reading it as DEAD tore down a live backend as "exited while its served
|
||||
// token was being resolved", and reading it as FOREIGN skipped the reap while
|
||||
// still removing the lockfile, leaving one orphaned `serve --isolated` per
|
||||
// attempt (#111810). Retry over a short window; with no definite answer fail
|
||||
// closed with a transient error so callers keep the ownership record.
|
||||
async function execProbeVerdict(ssh, command, sentinels, failureMessage) {
|
||||
for (let attempt = 0; attempt < PROBE_VERDICT_ATTEMPTS; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, PROBE_VERDICT_RETRY_MS))
|
||||
}
|
||||
|
||||
let out
|
||||
|
||||
try {
|
||||
out = String((await ssh.exec(command)) || '').trim()
|
||||
} catch (cause) {
|
||||
const error: any = new Error(failureMessage)
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
|
||||
if (sentinels.includes(out)) {
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
const error: any = new Error(failureMessage)
|
||||
error.kind = 'transient-transport-error'
|
||||
throw error
|
||||
}
|
||||
|
||||
async function remotePidAlive(ssh, pid) {
|
||||
if (!pid || !Number.isInteger(Number(pid))) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const out = (await ssh.exec(`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`)).trim()
|
||||
const verdict = await execProbeVerdict(
|
||||
ssh,
|
||||
`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`,
|
||||
['ALIVE', 'DEAD'],
|
||||
'Could not verify the SSH backend process.'
|
||||
)
|
||||
|
||||
return out === 'ALIVE'
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not verify the SSH backend process.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
return verdict === 'ALIVE'
|
||||
}
|
||||
|
||||
// Stable kernel process-start identity used to fence a later managed-update
|
||||
@@ -585,8 +624,7 @@ async function pidIsOurDashboard(
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const script =
|
||||
const script =
|
||||
'import os,shlex,subprocess,sys\n' +
|
||||
`pid=${Number(pid)}\n` +
|
||||
`expected=os.path.expanduser(${shq(hermesPath)})\n` +
|
||||
@@ -633,15 +671,14 @@ async function pidIsOurDashboard(
|
||||
'except (ValueError,IndexError):pass\n' +
|
||||
'print("OWNED" if ok else "FOREIGN")'
|
||||
|
||||
const out = await ssh.exec(`python3 -c ${shq(script)}`)
|
||||
const verdict = await execProbeVerdict(
|
||||
ssh,
|
||||
`python3 -c ${shq(script)}`,
|
||||
['OWNED', 'FOREIGN'],
|
||||
'Could not verify SSH backend process ownership.'
|
||||
)
|
||||
|
||||
return String(out || '').trim() === 'OWNED'
|
||||
} catch (cause) {
|
||||
const error: any = new Error('Could not verify SSH backend process ownership.')
|
||||
error.kind = 'transient-transport-error'
|
||||
error.cause = cause
|
||||
throw error
|
||||
}
|
||||
return verdict === 'OWNED'
|
||||
}
|
||||
|
||||
// Kill the stale dashboard ONLY if provably ours, then drop the lockfile.
|
||||
@@ -1123,8 +1160,11 @@ function buildSpawnCommand(hermesPath, profile, opts: any = {}) {
|
||||
async function remoteSupportsSshOwnership(ssh, hermesPath) {
|
||||
const hermes = expandRemotePath(hermesPath)
|
||||
|
||||
// The watchdog wraps the inner `serve --help` so the hung CLI is its direct
|
||||
// child and dies remotely instead of orphaning (#110478). The `$( (` space
|
||||
// is load-bearing: without it the shell parses `$((` as arithmetic expansion.
|
||||
const out = await ssh.exec(
|
||||
`help="$(${hermes} serve --help 2>&1)"; ` +
|
||||
`help="$( ${withRemoteTimeout(`${hermes} serve --help 2>&1`)} )"; ` +
|
||||
`printf '%s' "$help" | grep -q ssh-session-token-file && ` +
|
||||
`printf '%s' "$help" | grep -q ssh-owner-nonce && echo YES || echo NO`
|
||||
)
|
||||
@@ -1655,7 +1695,20 @@ async function connect(deps) {
|
||||
void 0
|
||||
}
|
||||
|
||||
await cleanupStale(ssh, ownershipId, ownedSpawn)
|
||||
// This record IS the child this attempt spawned. A liveness probe that
|
||||
// cannot be settled must not become "leave it running": assume alive so
|
||||
// cleanupStale re-runs the ownership proof, which keeps the record when
|
||||
// nothing can be proven and lets the next connect reap by exact ownership.
|
||||
const pidAlive = await remotePidAlive(ssh, pid).catch(() => true)
|
||||
|
||||
try {
|
||||
await cleanupStale(ssh, ownershipId, ownedSpawn, pidAlive)
|
||||
} catch (cleanupError) {
|
||||
// An unsettled ownership proof must not replace the boot failure the
|
||||
// user needs to see; keep it reachable for diagnostics instead.
|
||||
error.cleanupCause = cleanupError
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +208,40 @@ test('cancelAndWait force-cleans pending resources before awaiting rollback', as
|
||||
assert.equal(cleaned, 1)
|
||||
})
|
||||
|
||||
test('cancelAndWait keeps the drain up through afterCancel teardown', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const events: string[] = []
|
||||
let releaseAfter: (() => void) | undefined
|
||||
const afterGate = new Promise<void>(resolve => {
|
||||
releaseAfter = resolve
|
||||
})
|
||||
let teardownStarted: (() => void) | undefined
|
||||
const started = new Promise<void>(resolve => {
|
||||
teardownStarted = resolve
|
||||
})
|
||||
|
||||
const drain = coordinator.cancelAndWait('scope', async () => {
|
||||
events.push('teardown-start')
|
||||
teardownStarted?.()
|
||||
await afterGate
|
||||
events.push('teardown-done')
|
||||
})
|
||||
|
||||
await started
|
||||
const next = coordinator.start('scope', 'new', async () => {
|
||||
events.push('new-start')
|
||||
|
||||
return 'new'
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(events, ['teardown-start'])
|
||||
releaseAfter?.()
|
||||
await drain
|
||||
assert.equal(await next, 'new')
|
||||
assert.deepEqual(events, ['teardown-start', 'teardown-done', 'new-start'])
|
||||
})
|
||||
|
||||
test('a generation started during cancelAndWait cannot run before the drain completes', async () => {
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const oldGate = deferred()
|
||||
@@ -236,3 +270,39 @@ test('a generation started during cancelAndWait cannot run before the drain comp
|
||||
assert.equal(await next, 'new')
|
||||
assert.deepEqual(events, ['old-start', 'new-start'])
|
||||
})
|
||||
|
||||
test('a second cancelAndWait on the same scope composes with the teardown still in flight', async () => {
|
||||
// Pool stop is blocked in SSH teardown; a connection apply cancels the same
|
||||
// scope with no bootstrap left to drain. The apply's drain must not replace
|
||||
// and clear the barrier, or start() runs before the first teardown finishes.
|
||||
const coordinator = createBootstrapCoordinator()
|
||||
const events: string[] = []
|
||||
const teardownGate = deferred()
|
||||
const teardownStarted = deferred()
|
||||
|
||||
const poolStop = coordinator.cancelAndWait('scope', async () => {
|
||||
events.push('teardown-start')
|
||||
teardownStarted.resolve()
|
||||
await teardownGate.promise
|
||||
events.push('teardown-done')
|
||||
})
|
||||
|
||||
await teardownStarted.promise
|
||||
const apply = coordinator.cancelAndWait('scope').then(() => events.push('apply-drained'))
|
||||
const next = coordinator.start('scope', 'new', async () => {
|
||||
events.push('new-start')
|
||||
|
||||
return 'new'
|
||||
})
|
||||
|
||||
// A macrotask, not a microtask tick: nothing may run before the first teardown finishes.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
assert.deepEqual(events, ['teardown-start'])
|
||||
teardownGate.resolve()
|
||||
await Promise.all([poolStop, apply])
|
||||
assert.equal(await next, 'new')
|
||||
// Both the apply and the new bootstrap wake on the same drained barrier; only
|
||||
// their position after teardown-done is the contract.
|
||||
assert.deepEqual(events.slice(0, 2), ['teardown-start', 'teardown-done'])
|
||||
assert.deepEqual(events.slice(2).sort(), ['apply-drained', 'new-start'])
|
||||
})
|
||||
|
||||
@@ -93,14 +93,26 @@ function createBootstrapCoordinator() {
|
||||
pending.get(scope)?.controller.abort()
|
||||
}
|
||||
|
||||
async function cancelAndWait(scope) {
|
||||
async function cancelAndWait(scope, afterCancel?: () => Promise<void>) {
|
||||
let release
|
||||
|
||||
const barrier = new Promise<void>(resolve => {
|
||||
const own = new Promise<void>(resolve => {
|
||||
release = resolve
|
||||
})
|
||||
// Compose with any drain already in flight for this scope (a pool stop
|
||||
// still tearing down SSH while a connection apply cancels the same scope):
|
||||
// start() must wait for every active teardown, and the map entry is
|
||||
// cleared only once the composed barrier settles.
|
||||
const prior = drains.get(scope)
|
||||
// Drain barriers never reject, so chaining is equivalent to allSettled.
|
||||
const barrier = prior ? prior.then(() => own) : own
|
||||
|
||||
drains.set(scope, barrier)
|
||||
void barrier.finally(() => {
|
||||
if (drains.get(scope) === barrier) {
|
||||
drains.delete(scope)
|
||||
}
|
||||
})
|
||||
const entries = [...active].filter(entry => entry.scope === scope)
|
||||
|
||||
for (const entry of entries) {
|
||||
@@ -114,13 +126,18 @@ function createBootstrapCoordinator() {
|
||||
// drain barrier still prevents stale resurrection.
|
||||
await Promise.allSettled(entries.flatMap(entry => [...entry.forceCleanups]).map(cleanup => cleanup()))
|
||||
await Promise.allSettled(entries.map(entry => entry.promise))
|
||||
} finally {
|
||||
if (drains.get(scope) === barrier) {
|
||||
drains.delete(scope)
|
||||
// Keep the drain up through caller teardown (SSH keepalive / tunnel)
|
||||
// so a replacement start() cannot publish before the old scope is gone.
|
||||
if (afterCancel) {
|
||||
await afterCancel()
|
||||
}
|
||||
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
|
||||
// "Cancel and wait" means the scope is drained: callers tear down SSH right
|
||||
// after this returns, so wait for the composed barrier, not just our own.
|
||||
await barrier
|
||||
}
|
||||
|
||||
function cancelAll() {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
@@ -18,15 +20,19 @@ import {
|
||||
forwardSpec,
|
||||
hostArgs,
|
||||
redactSecrets,
|
||||
REMOTE_PROBE_TIMEOUT_SECS,
|
||||
runSsh,
|
||||
SSH_ERROR,
|
||||
SshConnection,
|
||||
sshErrorMessage,
|
||||
stopTunnelChild,
|
||||
target,
|
||||
validateSshTarget
|
||||
validateSshTarget,
|
||||
withRemoteTimeout
|
||||
} from './ssh-connection'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
test('redactSecrets scrubs the spawn-time session token env var', () => {
|
||||
const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=abc123deadbeef HERMES_DESKTOP=1 hermes dashboard'
|
||||
const out = redactSecrets(line)
|
||||
@@ -1067,3 +1073,93 @@ test('stopTunnelChild waits for process exit', async () => {
|
||||
await stopping
|
||||
assert.equal(stopped, true)
|
||||
})
|
||||
|
||||
test.skipIf(process.platform === 'win32')('withRemoteTimeout runs a healthy probe under a zsh login shell (#111949)', async t => {
|
||||
// SSH runs the remote command through the account's login shell. In
|
||||
// non-interactive zsh, a bare `set -m` is fatal, so the wrapper must still
|
||||
// run a healthy probe rather than reporting the remote as unsupported.
|
||||
const zsh = await execFileAsync('sh', ['-c', 'command -v zsh || true']).then(r => r.stdout.trim())
|
||||
|
||||
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
|
||||
// skip, not a pass, or a wrapper regression stays green unnoticed.
|
||||
if (!zsh) {
|
||||
t.skip('zsh not installed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const { stdout: zshStdout } = await execFileAsync(zsh, ['-fc', withRemoteTimeout('echo zsh-ok', 5)])
|
||||
|
||||
assert.equal(zshStdout, 'zsh-ok\n')
|
||||
})
|
||||
|
||||
test('withRemoteTimeout kills a hung probe remotely instead of orphaning it (#110478)', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
// Shape: POSIX watchdog — macOS remotes have no GNU `timeout`.
|
||||
const wrapped = withRemoteTimeout('hermes --version 2>&1', 15)
|
||||
|
||||
assert.ok(wrapped.includes('sleep 15'), 'watchdog duration honored')
|
||||
assert.ok(wrapped.includes('kill -9'), 'watchdog kills the hung child remotely')
|
||||
assert.ok(!/(^|[ ;(])timeout[ ;]/.test(wrapped), 'no GNU timeout dependency')
|
||||
assert.ok(wrapped.endsWith('exit $__htrc'), 'inner exit code propagated')
|
||||
assert.ok(
|
||||
withRemoteTimeout('true').includes(`sleep ${REMOTE_PROBE_TIMEOUT_SECS}`),
|
||||
'defaults to REMOTE_PROBE_TIMEOUT_SECS'
|
||||
)
|
||||
assert.ok(REMOTE_PROBE_TIMEOUT_SECS * 1000 < 20_000, 'remote watchdog fires before the local exec timeout')
|
||||
|
||||
// Behavior through a real POSIX shell: healthy output passes through …
|
||||
const healthyStart = Date.now()
|
||||
const { stdout } = await execFileAsync('sh', ['-c', withRemoteTimeout('echo hello', 5)])
|
||||
const healthyElapsed = Date.now() - healthyStart
|
||||
|
||||
assert.equal(stdout, 'hello\n')
|
||||
// … and returns promptly: the watchdog's orphaned `sleep` must not hold the
|
||||
// session pipes open until the full timeout on the healthy path.
|
||||
assert.ok(healthyElapsed < 4000, `healthy probe returned fast (took ${healthyElapsed}ms)`)
|
||||
|
||||
// … a hung command is killed promptly with a non-zero exit … The duration
|
||||
// is unique to this run so the orphan sweep below cannot match an unrelated
|
||||
// `sleep` on a busy host.
|
||||
const hungSecs = 30_000 + (process.pid % 10_000)
|
||||
const start = Date.now()
|
||||
|
||||
const err: any = await execFileAsync('sh', ['-c', withRemoteTimeout(`sleep ${hungSecs}`, 1)]).then(
|
||||
() => null,
|
||||
e => e
|
||||
)
|
||||
|
||||
const elapsed = Date.now() - start
|
||||
|
||||
assert.ok(err && err.code !== 0, 'hung command must exit non-zero')
|
||||
assert.ok(elapsed < 15000, `watchdog fired promptly instead of waiting ${hungSecs}s (took ${elapsed}ms)`)
|
||||
|
||||
// … and no orphan is left behind.
|
||||
const { stdout: strays } = await execFileAsync('sh', ['-c', `ps -eo args | grep "[s]leep ${hungSecs}$" || true`])
|
||||
|
||||
assert.equal(strays.trim(), '', 'killed probe left no orphan process')
|
||||
|
||||
// … including the grandchild of a launcher that runs the CLI without exec
|
||||
// (the broken-launcher class of #110478). Needs a shell with job control
|
||||
// off a tty; bash has it, dash does not.
|
||||
const bash = await execFileAsync('sh', ['-c', 'command -v bash || true']).then(r => r.stdout.trim())
|
||||
|
||||
if (bash) {
|
||||
const grandSecs = hungSecs + 1
|
||||
const launcher = `sh -c 'sleep ${grandSecs}; echo done'`
|
||||
|
||||
const err2: any = await execFileAsync(bash, ['-c', withRemoteTimeout(launcher, 1)]).then(
|
||||
() => null,
|
||||
e => e
|
||||
)
|
||||
|
||||
assert.ok(err2 && err2.code !== 0, 'hung launcher must exit non-zero')
|
||||
|
||||
const { stdout: grandStrays } = await execFileAsync('sh', ['-c', `ps -eo args | grep "[s]leep ${grandSecs}$" || true`])
|
||||
|
||||
assert.equal(grandStrays.trim(), '', 'watchdog killed the launcher’s grandchild too')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,6 +39,13 @@ import path from 'node:path'
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
|
||||
const DEFAULT_EXEC_TIMEOUT_MS = 20_000
|
||||
const DEFAULT_FORWARD_TIMEOUT_MS = 15_000
|
||||
|
||||
// Remote-side watchdog for probe commands, in seconds. runSsh SIGKILLs the
|
||||
// LOCAL ssh child on timeout, but the remote command keeps running as an
|
||||
// orphan (ppid=1) — a hung remote CLI (e.g. a wedged `hermes --version`)
|
||||
// accumulates orphans that busy-loop (#110478). Kept under
|
||||
// DEFAULT_EXEC_TIMEOUT_MS so the remote kill lands before the local timeout.
|
||||
const REMOTE_PROBE_TIMEOUT_SECS = 15
|
||||
// No-mux tunnels are one `ssh -N -L` child each; a transient child death
|
||||
// (network blip, sshd restart, laptop resume) used to instantly poison
|
||||
// isAlive() and cascade upstream into a full teardown that SIGTERM'd a
|
||||
@@ -321,6 +328,40 @@ function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs?, remoteComma
|
||||
return args
|
||||
}
|
||||
|
||||
// Wrap a remote probe command in a POSIX watchdog so a hung remote CLI is
|
||||
// killed REMOTELY after `timeoutSecs` instead of orphaning when the local ssh
|
||||
// child is SIGKILLed (#110478). Pure POSIX sh (dash, macOS sh) — deliberately
|
||||
// not GNU `timeout`, which macOS remotes do not ship.
|
||||
//
|
||||
// The wrapped command must be a SINGLE command: the watchdog kills its direct
|
||||
// child, so the exact invocation that can hang must be the direct child —
|
||||
// a hung grandchild of a compound wrapper would orphan anyway. (The ownership
|
||||
// probe nests the watchdog around the inner `serve --help` inside its
|
||||
// `$( ... )` for this reason; note the load-bearing space in `$( (`.)
|
||||
// The wrapped command keeps its stdout; the shell exits non-zero when the
|
||||
// watchdog fires and the probe's existing failure path handles it.
|
||||
//
|
||||
// The sleeper's stdio is detached (</dev/null >/dev/null 2>&1): killing the
|
||||
// sleeper subshell orphans its `sleep` grandchild, and an orphan holding the
|
||||
// session pipes would keep the ssh channel open until the full timeout even on
|
||||
// the healthy path. Detached, the orphan is a benign self-reaping `sleep`.
|
||||
function withRemoteTimeout(remoteCommand, timeoutSecs = REMOTE_PROBE_TIMEOUT_SECS) {
|
||||
const secs = Number.isFinite(timeoutSecs) && timeoutSecs > 0 ? Math.floor(timeoutSecs) : REMOTE_PROBE_TIMEOUT_SECS
|
||||
|
||||
// Job control (`set -m`) puts the probe in its own process group so the
|
||||
// watchdog can also reach a grandchild left behind by a launcher that runs
|
||||
// the CLI without exec. Non-interactive zsh exits when asked to enable
|
||||
// monitor mode, so skip that setup there and fall back to killing the direct
|
||||
// child. Other shells retain the process-group cleanup where supported.
|
||||
return (
|
||||
`[ -n "\${ZSH_VERSION-}" ] || set -m 2>/dev/null; (${remoteCommand}) </dev/null & __htp=$!; set +m 2>/dev/null; ` +
|
||||
`(sleep ${secs} </dev/null >/dev/null 2>&1; kill -9 -- -$__htp 2>/dev/null; kill -9 $__htp 2>/dev/null) & __htw=$!; ` +
|
||||
`wait $__htp; __htrc=$?; ` +
|
||||
`kill $__htw 2>/dev/null; wait $__htw 2>/dev/null; ` +
|
||||
`exit $__htrc`
|
||||
)
|
||||
}
|
||||
|
||||
// Bind the local end to 127.0.0.1 ONLY — never 0.0.0.0 — so the tunnel does not
|
||||
// re-expose the remote dashboard to the client's LAN.
|
||||
function forwardSpec(localPort, remotePort, remoteHost = '127.0.0.1') {
|
||||
@@ -1137,6 +1178,7 @@ export {
|
||||
hostArgs,
|
||||
pickLocalPort,
|
||||
redactSecrets,
|
||||
REMOTE_PROBE_TIMEOUT_SECS,
|
||||
runSsh,
|
||||
SSH_ERROR,
|
||||
SshConnection,
|
||||
@@ -1144,5 +1186,6 @@ export {
|
||||
stopTunnelChild,
|
||||
target,
|
||||
validateKeyPath,
|
||||
validateSshTarget
|
||||
validateSshTarget,
|
||||
withRemoteTimeout
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* #106935: Desktop main must hold a long-lived keep-alive WebSocket for every
|
||||
* published SSH-isolated backend. Idle-exit (#101626) treats accepted WS as
|
||||
* ownership liveness; renderer sockets can drop while sshConnections still owns
|
||||
* the scope. Sticky artifacts (nonce / token file / lockfile) are NOT liveness.
|
||||
*
|
||||
* The pool-stop teardown fence that closes this socket is asserted in
|
||||
* pool-stop.test.ts (afterStop) — AGENTS.md forbids reading `.ts` source from tests.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } {
|
||||
const instances: any[] = []
|
||||
|
||||
class FakeWs {
|
||||
url: string
|
||||
closed = false
|
||||
listeners: Record<string, Array<(event?: any) => void>> = {}
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
instances.push(this)
|
||||
}
|
||||
|
||||
addEventListener(type: string, fn: (event?: any) => void) {
|
||||
;(this.listeners[type] ||= []).push(fn)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true
|
||||
}
|
||||
|
||||
emit(type: string, event?: any) {
|
||||
for (const fn of this.listeners[type] || []) {
|
||||
fn(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { FakeWs, instances }
|
||||
}
|
||||
|
||||
describe('ssh-isolated keep-alive registry (#106935)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('holds an open WebSocket per scope until that scope stops, then never reconnects it', async () => {
|
||||
const { createSshIsolatedKeepaliveRegistry } = await import('./ssh-isolated-keepalive')
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
const registry = createSshIsolatedKeepaliveRegistry({
|
||||
WebSocketImpl: FakeWs,
|
||||
reconnectDelayMs: 25
|
||||
})
|
||||
|
||||
registry.start('conn:office::work', {
|
||||
baseUrl: 'http://127.0.0.1:53101',
|
||||
token: 'sess-work'
|
||||
})
|
||||
registry.start('conn:office::less', { baseUrl: 'http://127.0.0.1:53102', token: 'sess-less' })
|
||||
|
||||
expect(instances).toHaveLength(2)
|
||||
expect(instances[0].url).toBe('ws://127.0.0.1:53101/api/ws?token=sess-work')
|
||||
expect(registry.isArmed('conn:office::work')).toBe(true)
|
||||
|
||||
instances[0].emit('open')
|
||||
expect(registry.openUrl('conn:office::work')).toBe('ws://127.0.0.1:53101/api/ws?token=sess-work')
|
||||
|
||||
// A dropped socket is redialled with backoff (25 → 50 ms), so a dead tunnel is not
|
||||
// hammered every interval until the scope is torn down.
|
||||
vi.useFakeTimers()
|
||||
instances[1].emit('close', { code: 1006 })
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
expect(instances).toHaveLength(3)
|
||||
instances[2].emit('close', { code: 1006 })
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
expect(instances).toHaveLength(3)
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
expect(instances).toHaveLength(4)
|
||||
vi.useRealTimers()
|
||||
|
||||
registry.stop('conn:office::work')
|
||||
expect(instances[0].closed).toBe(true)
|
||||
expect(registry.isArmed('conn:office::work')).toBe(false)
|
||||
// Tearing down one sibling must not drop the other owned scope.
|
||||
expect(instances[3].closed).toBe(false)
|
||||
expect(registry.isArmed('conn:office::less')).toBe(true)
|
||||
|
||||
instances[0].emit('close', { code: 1006 })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(instances).toHaveLength(4)
|
||||
expect(registry.openUrl('conn:office::work')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats empty-string scope as the v1/global SSH primary but still requires baseUrl and token', async () => {
|
||||
const { createSshIsolatedKeepaliveRegistry } = await import('./ssh-isolated-keepalive')
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
const registry = createSshIsolatedKeepaliveRegistry({ WebSocketImpl: FakeWs })
|
||||
|
||||
registry.start('', { baseUrl: '', token: 'tok' })
|
||||
registry.start('', { baseUrl: 'http://127.0.0.1:9', token: '' })
|
||||
expect(instances).toHaveLength(0)
|
||||
expect(registry.isArmed('')).toBe(false)
|
||||
|
||||
registry.start('', { baseUrl: 'http://127.0.0.1:53100', token: 'primary' })
|
||||
|
||||
expect(instances).toHaveLength(1)
|
||||
expect(instances[0].url).toBe('ws://127.0.0.1:53100/api/ws?token=primary')
|
||||
expect(registry.isArmed('')).toBe(true)
|
||||
|
||||
instances[0].emit('open')
|
||||
expect(registry.openUrl('')).toBe('ws://127.0.0.1:53100/api/ws?token=primary')
|
||||
|
||||
registry.stop('')
|
||||
expect(instances[0].closed).toBe(true)
|
||||
expect(registry.isArmed('')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Long-lived keep-alive WebSocket from Electron main for every published
|
||||
* SSH-isolated backend (#106935).
|
||||
*
|
||||
* `web_server_idle_exit` (#101626) treats accepted WebSockets as ownership
|
||||
* liveness. Renderer sockets can vanish (`disposeSecondary` /
|
||||
* `pruneSecondaryGateways`) while Desktop still owns the backend via
|
||||
* `sshConnections`. Sticky spawn artifacts (owner-nonce, token file, lockfile)
|
||||
* are NOT liveness and must not suppress idle-exit.
|
||||
*
|
||||
* If the socket drops the registry reconnects with capped exponential backoff;
|
||||
* while it is down the backend may idle-exit as before. This module never consults
|
||||
* nonce/lock/token files.
|
||||
*/
|
||||
import { buildGatewayWsUrl } from './connection-config'
|
||||
|
||||
export type SshIsolatedKeepaliveTarget = {
|
||||
baseUrl: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export type SshIsolatedKeepaliveOptions = {
|
||||
WebSocketImpl?: any
|
||||
buildWsUrl?: (baseUrl: string, token: string) => string
|
||||
log?: (message: string) => void
|
||||
reconnectDelayMs?: number
|
||||
}
|
||||
|
||||
type KeepaliveEntry = {
|
||||
failures: number
|
||||
reconnectTimer: ReturnType<typeof setTimeout> | null
|
||||
scope: string
|
||||
socket: { close?: () => void; url?: string } | null
|
||||
target: SshIsolatedKeepaliveTarget
|
||||
}
|
||||
|
||||
const DEFAULT_RECONNECT_DELAY_MS = 2_000
|
||||
const MAX_RECONNECT_DELAY_MS = 30_000
|
||||
|
||||
function addListener(socket: any, type: string, handler: (event?: any) => void) {
|
||||
if (typeof socket?.addEventListener === 'function') {
|
||||
socket.addEventListener(type, handler)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof socket?.on === 'function') {
|
||||
socket.on(type, handler)
|
||||
}
|
||||
}
|
||||
|
||||
export function createSshIsolatedKeepaliveRegistry(options: SshIsolatedKeepaliveOptions = {}) {
|
||||
const WebSocketImpl =
|
||||
'WebSocketImpl' in options ? options.WebSocketImpl : (globalThis as { WebSocket?: unknown }).WebSocket
|
||||
const buildWsUrl = options.buildWsUrl ?? buildGatewayWsUrl
|
||||
const log = options.log
|
||||
const reconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS
|
||||
const entries = new Map<string, KeepaliveEntry>()
|
||||
|
||||
function clearTimer(entry: KeepaliveEntry) {
|
||||
if (entry.reconnectTimer == null) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(entry.reconnectTimer)
|
||||
entry.reconnectTimer = null
|
||||
}
|
||||
|
||||
function closeSocket(entry: KeepaliveEntry) {
|
||||
const socket = entry.socket
|
||||
entry.socket = null
|
||||
|
||||
if (!socket) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
socket.close?.()
|
||||
} catch {
|
||||
// Best-effort teardown; a dead socket must not block scope cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(entry: KeepaliveEntry) {
|
||||
if (entries.get(entry.scope) !== entry || entry.reconnectTimer != null) {
|
||||
return
|
||||
}
|
||||
|
||||
// A dead tunnel would otherwise be redialled every 2 s until the scope is torn down.
|
||||
const delay = Math.min(reconnectDelayMs * 2 ** entry.failures, MAX_RECONNECT_DELAY_MS)
|
||||
entry.failures += 1
|
||||
entry.reconnectTimer = setTimeout(() => {
|
||||
entry.reconnectTimer = null
|
||||
connect(entry)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function connect(entry: KeepaliveEntry) {
|
||||
if (entries.get(entry.scope) !== entry) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimer(entry)
|
||||
closeSocket(entry)
|
||||
|
||||
let socket: any
|
||||
let url: string
|
||||
|
||||
try {
|
||||
url = buildWsUrl(entry.target.baseUrl, entry.target.token)
|
||||
socket = new WebSocketImpl(url)
|
||||
} catch (error) {
|
||||
log?.(`[ssh] keep-alive WS failed to open for ${entry.scope}: ${error instanceof Error ? error.message : error}`)
|
||||
scheduleReconnect(entry)
|
||||
return
|
||||
}
|
||||
|
||||
entry.socket = socket
|
||||
|
||||
// Staleness is derivable: stop() removes the entry, connect() replaces entry.socket.
|
||||
const abandonIfStale = () => {
|
||||
if (entries.get(entry.scope) !== entry || entry.socket !== socket) {
|
||||
return
|
||||
}
|
||||
|
||||
entry.socket = null
|
||||
scheduleReconnect(entry)
|
||||
}
|
||||
|
||||
addListener(socket, 'open', () => {
|
||||
if (entry.socket === socket) {
|
||||
entry.failures = 0
|
||||
}
|
||||
})
|
||||
addListener(socket, 'close', abandonIfStale)
|
||||
addListener(socket, 'error', abandonIfStale)
|
||||
}
|
||||
|
||||
function start(scope: string, target: SshIsolatedKeepaliveTarget) {
|
||||
// '' is a real published key: sshScopeKey(null) for the v1/global SSH primary.
|
||||
if (typeof scope !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = typeof target?.baseUrl === 'string' ? target.baseUrl : ''
|
||||
const token = typeof target?.token === 'string' ? target.token : ''
|
||||
|
||||
if (!baseUrl || !token) {
|
||||
return
|
||||
}
|
||||
|
||||
stop(scope)
|
||||
|
||||
const entry: KeepaliveEntry = {
|
||||
failures: 0,
|
||||
reconnectTimer: null,
|
||||
scope,
|
||||
socket: null,
|
||||
target: { baseUrl, token }
|
||||
}
|
||||
entries.set(scope, entry)
|
||||
connect(entry)
|
||||
}
|
||||
|
||||
function stop(scope: string) {
|
||||
const entry = entries.get(scope)
|
||||
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
entries.delete(scope)
|
||||
clearTimer(entry)
|
||||
closeSocket(entry)
|
||||
}
|
||||
|
||||
function stopAll() {
|
||||
for (const scope of [...entries.keys()]) {
|
||||
stop(scope)
|
||||
}
|
||||
}
|
||||
|
||||
function isArmed(scope: string) {
|
||||
return entries.has(scope)
|
||||
}
|
||||
|
||||
function openUrl(scope: string) {
|
||||
const url = entries.get(scope)?.socket?.url
|
||||
return typeof url === 'string' ? url : null
|
||||
}
|
||||
|
||||
return { isArmed, openUrl, start, stop, stopAll }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
collectRelaunchArgs,
|
||||
describeUpdaterHandoffFailure,
|
||||
MARKER_SELF_ADOPT_EPOCH_MS,
|
||||
observeUpdaterHandoff,
|
||||
resolvePosixScriptHandoff,
|
||||
@@ -393,6 +394,20 @@ test('observeUpdaterHandoff reports a non-zero early exit', async () => {
|
||||
assert.equal(outcome.code, 127)
|
||||
})
|
||||
|
||||
test('describeUpdaterHandoffFailure leads with plain copy and confines the raw outcome to Details', () => {
|
||||
for (const raw of ['updater exited 127 before the settle window elapsed', 'updater spawn failed: ENOENT']) {
|
||||
const text = describeUpdaterHandoffFailure({ message: raw })
|
||||
const [lead, details] = text.split('\n\nDetails: ')
|
||||
|
||||
assert.match(lead, /Hermes keeps running/)
|
||||
assert.match(lead, /Try again/)
|
||||
assert.doesNotMatch(lead, /exited|spawn|ENOENT|settle window|hermes update|\d/)
|
||||
assert.equal(details, raw)
|
||||
}
|
||||
|
||||
assert.doesNotMatch(describeUpdaterHandoffFailure({}), /Details:/)
|
||||
})
|
||||
|
||||
test('observeUpdaterHandoff reports a signal death inside the window', async () => {
|
||||
const child = new FakeChild()
|
||||
const timer = manualTimer()
|
||||
|
||||
@@ -357,6 +357,20 @@ export interface ObserveUpdaterHandoffDeps {
|
||||
clearTimeoutFn?: (timer: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing copy for a hand-off that did not take (spawn error or early exit).
|
||||
* The lead sentence is plain: nothing changed and Hermes keeps running. The raw
|
||||
* outcome message (exit code / signal / spawn error) stays on a trailing
|
||||
* "Details:" line for logs and support.
|
||||
*/
|
||||
export function describeUpdaterHandoffFailure(outcome: Pick<UpdaterHandoffOutcome, 'message'>): string {
|
||||
const lead =
|
||||
"The updater couldn't start, so nothing was changed and Hermes keeps running as before. " +
|
||||
'Try again; if it keeps failing, open the logs and send them to support.'
|
||||
|
||||
return outcome.message ? `${lead}\n\nDetails: ${outcome.message}` : lead
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a just-spawned detached updater for the duration of the quit dwell
|
||||
* and report whether the hand-off actually became viable (#66753).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { type EnumeratedWindow, enumerationFailureNote, pickWindowBelow } from './window-below'
|
||||
import { type EnumeratedWindow, enumerationFailureNote, pickWindowBelow, resolveOutsideAsar } from './window-below'
|
||||
|
||||
const win = (pid: number, x = 0, y = 0, width = 800, height = 600, app = `app-${pid}`): EnumeratedWindow => ({
|
||||
app,
|
||||
@@ -145,3 +145,33 @@ describe('enumerationFailureNote', () => {
|
||||
expect(note).not.toMatch(/ENOENT/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveOutsideAsar', () => {
|
||||
// The helper binary get-windows execs cannot run from inside the archive
|
||||
// (execFile on a path through app.asar fails ENOTDIR), so the import must
|
||||
// land on the unpacked copy electron-builder ships beside it.
|
||||
it('redirects a packaged specifier into app.asar.unpacked', () => {
|
||||
expect(
|
||||
resolveOutsideAsar('file:///Applications/Hermes.app/Contents/Resources/app.asar/dist/node_modules/get-windows/index.js')
|
||||
).toBe('file:///Applications/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/get-windows/index.js')
|
||||
})
|
||||
|
||||
// The staged specifier is built with path.join, so on Windows the archive
|
||||
// segment is delimited by backslashes, not the slashes a file: URL has.
|
||||
it('redirects a Windows packaged path built with backslashes', () => {
|
||||
expect(resolveOutsideAsar('C:\\Users\\me\\AppData\\Local\\Hermes\\resources\\app.asar\\dist\\node_modules\\get-windows\\index.js')).toBe(
|
||||
'C:\\Users\\me\\AppData\\Local\\Hermes\\resources\\app.asar.unpacked\\dist\\node_modules\\get-windows\\index.js'
|
||||
)
|
||||
})
|
||||
|
||||
// Only the exact archive segment counts — a directory that merely starts
|
||||
// with the name must not be rewritten, and a dev tree has nothing to rewrite.
|
||||
it('requires app.asar to be a complete path segment', () => {
|
||||
for (const untouched of [
|
||||
'file:///opt/app.asar-tools/node_modules/get-windows/index.js',
|
||||
'file:///Users/dev/hermes-agent/node_modules/get-windows/index.js'
|
||||
]) {
|
||||
expect(resolveOutsideAsar(untouched)).toBe(untouched)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -148,6 +148,17 @@ export const enumerationFailed = <T>(result: EnumerationFailure | T): result is
|
||||
const describeError = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error ?? 'unknown error')
|
||||
|
||||
/**
|
||||
* Redirect a resolved module specifier out of `app.asar` so its files exist on
|
||||
* the real filesystem. `app.asar` only ever appears as a complete path
|
||||
* segment in a packaged build (electron-builder names the archive exactly
|
||||
* that), so in dev — where nothing is archived — this is a no-op. The segment
|
||||
* is matched against either separator because the staged specifier comes from
|
||||
* `path.join`, which on Windows yields backslashes; same regex as main.ts.
|
||||
*/
|
||||
export const resolveOutsideAsar = (specifier: string): string =>
|
||||
specifier.replace(/app\.asar(?=$|[\\/])/, 'app.asar.unpacked')
|
||||
|
||||
let getWindowsModule: Promise<GetWindowsModule | EnumerationFailure> | null = null
|
||||
|
||||
const loadGetWindows = (): Promise<GetWindowsModule | EnumerationFailure> => {
|
||||
@@ -169,7 +180,18 @@ const loadGetWindows = (): Promise<GetWindowsModule | EnumerationFailure> => {
|
||||
// scripts/stage-native-deps.mjs writes a staged lib/windows.js that requires
|
||||
// the binding directly, so it is the more reliable of the two everywhere.
|
||||
getWindowsModule ??= (async () => {
|
||||
const staged = path.join(app.getAppPath(), 'dist', 'node_modules', 'get-windows', 'index.js')
|
||||
// Both specifiers are redirected out of app.asar in a packaged build.
|
||||
// get-windows does its real work by exec'ing a helper binary whose path it
|
||||
// derives from its own module URL, so a module imported through an
|
||||
// `/app.asar/` path derives an in-archive helper path and the spawn fails
|
||||
// ENOTDIR: the kernel sees app.asar as a file. Electron rewrites asar
|
||||
// paths for child_process only once that module has been pulled through
|
||||
// the CJS loader, which this ESM main process never does (every import
|
||||
// here is `from 'node:child_process'`). Pointing the import at the
|
||||
// unpacked copy gives get-windows a real path to derive from.
|
||||
const staged = resolveOutsideAsar(
|
||||
path.join(app.getAppPath(), 'dist', 'node_modules', 'get-windows', 'index.js')
|
||||
)
|
||||
let stagedError = 'not staged in this build'
|
||||
|
||||
if (fs.existsSync(staged)) {
|
||||
@@ -181,7 +203,7 @@ const loadGetWindows = (): Promise<GetWindowsModule | EnumerationFailure> => {
|
||||
}
|
||||
|
||||
try {
|
||||
return (await import('get-windows')) as GetWindowsModule
|
||||
return (await import(resolveOutsideAsar(import.meta.resolve('get-windows')))) as GetWindowsModule
|
||||
} catch (error) {
|
||||
return {
|
||||
reason:
|
||||
|
||||
@@ -90,7 +90,7 @@ function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>
|
||||
isCommandScript: () => false,
|
||||
fileExists: () => true,
|
||||
directoryExists: () => false,
|
||||
canImportHermesCli: () => true,
|
||||
canImportHermesCli: async () => true,
|
||||
getVenvPython: (venvRoot: string) => `${venvRoot}/Scripts/python.exe`,
|
||||
getVenvSitePackagesEntries: () => [],
|
||||
buildDesktopBackendEnv: () => ({ FAKE_ENV: '1' }),
|
||||
@@ -103,41 +103,41 @@ function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>
|
||||
}
|
||||
}
|
||||
|
||||
test('resolveVenvHermesCommand: returns null off Windows', () => {
|
||||
test('resolveVenvHermesCommand: returns null off Windows', async () => {
|
||||
const deps = makeDeps({ isWindows: false })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
|
||||
assert.equal(await resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null for a .cmd/.bat script command', () => {
|
||||
test('resolveVenvHermesCommand: returns null for a .cmd/.bat script command', async () => {
|
||||
const deps = makeDeps({ isCommandScript: () => true })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.cmd', [], deps), null)
|
||||
assert.equal(await resolveVenvHermesCommand('/root/venv/Scripts/hermes.cmd', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the basename is not hermes/hermes.exe', () => {
|
||||
test('resolveVenvHermesCommand: returns null when the basename is not hermes/hermes.exe', async () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/python.exe', [], deps), null)
|
||||
assert.equal(await resolveVenvHermesCommand('/root/venv/Scripts/python.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the parent dir is not Scripts', () => {
|
||||
test('resolveVenvHermesCommand: returns null when the parent dir is not Scripts', async () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/bin/hermes.exe', [], deps), null)
|
||||
assert.equal(await resolveVenvHermesCommand('/root/venv/bin/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the venv python does not exist on disk', () => {
|
||||
test('resolveVenvHermesCommand: returns null when the venv python does not exist on disk', async () => {
|
||||
const deps = makeDeps({ fileExists: () => false })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
|
||||
assert.equal(await resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: probes the venv python before trusting it (returns null on failed probe)', () => {
|
||||
test('resolveVenvHermesCommand: probes the venv python before trusting it (returns null on failed probe)', async () => {
|
||||
let probed = false
|
||||
|
||||
const deps = makeDeps({
|
||||
canImportHermesCli: (python: string) => {
|
||||
canImportHermesCli: async (python: string) => {
|
||||
probed = true
|
||||
assert.equal(python, '/root/venv/Scripts/python.exe')
|
||||
|
||||
@@ -145,15 +145,15 @@ test('resolveVenvHermesCommand: probes the venv python before trusting it (retur
|
||||
}
|
||||
})
|
||||
|
||||
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve'], deps)
|
||||
const result = await resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve'], deps)
|
||||
|
||||
assert.equal(probed, true, 'must probe the venv interpreter; a broken venv must not be re-selected forever')
|
||||
assert.equal(result, null, 'a failed probe must fall through (return null) so the resolver reaches bootstrap')
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns the resolved python backend descriptor when the probe passes', () => {
|
||||
test('resolveVenvHermesCommand: returns the resolved python backend descriptor when the probe passes', async () => {
|
||||
const deps = makeDeps()
|
||||
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve', '--port', '0'], deps)
|
||||
const result = await resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve', '--port', '0'], deps)
|
||||
|
||||
assert.ok(result, 'a passing probe must return a backend descriptor, not null')
|
||||
assert.equal(result.command, '/root/venv/Scripts/python.exe')
|
||||
@@ -164,11 +164,11 @@ test('resolveVenvHermesCommand: returns the resolved python backend descriptor w
|
||||
assert.deepEqual(result.env, { FAKE_ENV: '1' })
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Scripts dir name', () => {
|
||||
test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Scripts dir name', async () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.ok(resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps))
|
||||
assert.ok(resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps))
|
||||
assert.ok(await resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps))
|
||||
assert.ok(await resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps))
|
||||
})
|
||||
|
||||
// ── getVenvSitePackagesEntries ─────────────────────────────────────────────
|
||||
@@ -194,13 +194,15 @@ test('getVenvSitePackagesEntries: returns empty on Windows when site-packages do
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: reads pyvenv.cfg version on POSIX and resolves lib/pythonX.Y/site-packages', () => {
|
||||
const expected = path.join('/venv', 'lib', 'python3.12', 'site-packages')
|
||||
|
||||
const result = getVenvSitePackagesEntries('/venv', {
|
||||
isWindows: false,
|
||||
directoryExists: p => p === '/venv/lib/python3.12/site-packages',
|
||||
directoryExists: p => p === expected,
|
||||
readFile: () => 'version_info = 3.12.1\n'
|
||||
})
|
||||
|
||||
assert.deepEqual(result, ['/venv/lib/python3.12/site-packages'])
|
||||
assert.deepEqual(result, [expected])
|
||||
})
|
||||
|
||||
test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg is missing', () => {
|
||||
|
||||
@@ -171,7 +171,7 @@ export interface ResolveVenvHermesCommandDeps {
|
||||
isCommandScript: (command: string) => boolean
|
||||
fileExists: (filePath: string) => boolean
|
||||
directoryExists: (filePath: string) => boolean
|
||||
canImportHermesCli: (python: string, opts?: { env?: Record<string, string> }) => boolean
|
||||
canImportHermesCli: (python: string, opts?: { env?: Record<string, string> }) => Promise<boolean>
|
||||
getVenvPython: (venvRoot: string) => string
|
||||
getVenvSitePackagesEntries: (venvRoot: string) => string[]
|
||||
buildDesktopBackendEnv: (opts: {
|
||||
@@ -205,11 +205,11 @@ export interface ResolveVenvHermesCommandDeps {
|
||||
* python doesn't exist, or the import probe fails. Otherwise returns the
|
||||
* resolved backend descriptor.
|
||||
*/
|
||||
export function resolveVenvHermesCommand(
|
||||
export async function resolveVenvHermesCommand(
|
||||
command: string,
|
||||
backendArgs: string[],
|
||||
deps: ResolveVenvHermesCommandDeps
|
||||
): {
|
||||
): Promise<{
|
||||
label: string
|
||||
command: string
|
||||
args: string[]
|
||||
@@ -218,7 +218,7 @@ export function resolveVenvHermesCommand(
|
||||
kind: 'python'
|
||||
root: string
|
||||
shell: false
|
||||
} | null {
|
||||
} | null> {
|
||||
const {
|
||||
isWindows,
|
||||
isCommandScript,
|
||||
@@ -261,13 +261,13 @@ export function resolveVenvHermesCommand(
|
||||
const root = dirname(venvRoot)
|
||||
|
||||
if (
|
||||
!canImportHermesCli(python, {
|
||||
!(await canImportHermesCli(python, {
|
||||
env: {
|
||||
PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH]
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(path.delimiter)
|
||||
}
|
||||
})
|
||||
}))
|
||||
) {
|
||||
rememberLog?.(
|
||||
`Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "hermes",
|
||||
"productName": "Hermes",
|
||||
"private": true,
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.3",
|
||||
"description": "Native desktop shell for Hermes Agent.",
|
||||
"author": "Nous Research",
|
||||
"repository": {
|
||||
|
||||
@@ -15,7 +15,7 @@ The desktop has **no build/runtime dependency on the dashboard frontend**: it sp
|
||||
NOT embed `hermes --tui` — own composer, transcript, slash pipeline.
|
||||
|
||||
**One backward-compat fallback:** `serve` is newer, so the spawn (`electron/backend-command.ts` +
|
||||
`backendSupportsServe()` in `electron/main.ts`) checks whether the resolved runtime registers `serve`
|
||||
`createBackendServeSupportResolver()` in `electron/backend-serve-support.ts`) checks whether the runtime registers `serve`
|
||||
and ONLY when it does not (older managed install / PATH `hermes` not yet updated) rewrites argv to
|
||||
legacy `dashboard --no-open`. Without it a new app against an un-upgraded runtime crashes on an
|
||||
unknown subcommand and bricks every mid-upgrade user. Keep it narrow and tested.
|
||||
@@ -23,13 +23,29 @@ unknown subcommand and bricks every mid-upgrade user. Keep it narrow and tested.
|
||||
Lifecycle: `serve` dies with the app by design; the messaging gateway survives it (spawned detached
|
||||
via `/api/gateway/*`). Never re-parent the gateway under the backend — `gateway/AGENTS.md`.
|
||||
|
||||
The backend the app spawns is a **pooled `hermes serve --port 0` per (connection, profile)**: its
|
||||
launch home is that profile, `HERMES_DESKTOP=1` is set, and its in-process cron ticker stands down
|
||||
for homes a running gateway already serves. One process may still host sessions from several homes
|
||||
(`tui_gateway/AGENTS.md` § Profile scope); the first non-launch home flips `set_multiplex_active`.
|
||||
Remote connections (SSH, URL+token, Cloud) reach a backend with no desktop env var that may serve
|
||||
several profiles from one process. Every lifecycle/status/settings REST call against a pooled
|
||||
backend carries `?profile=` (or the `profile` param) and every new-session tile records an owner
|
||||
route; a backend-side scope fix is probed twice — with the profile as the launch home of a pooled
|
||||
backend (env-bound) and as a secondary served by one process (override-bound).
|
||||
|
||||
## Slash commands: curated client-side, dispatched to the backend
|
||||
|
||||
- The backend already provides everything: `commands.catalog` and `complete.slash` include built-ins,
|
||||
user `quick_commands`, AND skill-derived commands. No new RPC is needed to see skills.
|
||||
- `src/lib/desktop-slash-commands.ts` is the load-bearing file: `DESKTOP_COMMAND_SPECS` (built-ins
|
||||
and their desktop surfaces) + `NO_DESKTOP_SURFACE` block-lists (terminal-only / messaging-only /
|
||||
picker-owned / settings-owned / advanced). `isDesktopSlashCommand(name)` gates **execution** (true
|
||||
and their desktop surfaces) + the block-list. A command's desktop disposition (terminal-only /
|
||||
messaging-only / settings-owned / advanced / hidden) is authored ONCE, as `desktop=` on its
|
||||
`CommandDef` in `hermes_cli/commands.py`; the live `commands.catalog` carries it, and
|
||||
`src/lib/desktop-slash-registry.json` (regenerate with `scripts/dump_desktop_slash_registry.py`;
|
||||
`tests/hermes_cli/test_desktop_slash_registry.py` + the vitest file fail on drift) is the offline
|
||||
fallback. Only names the Python registry has never heard of (`/density`, `/details`, `/logs`,
|
||||
`/mouse` — Ink-local; `/pets`) live in `TS_ONLY_NO_DESKTOP_SURFACE`. `isDesktopSlashCommand(name)`
|
||||
gates **execution** (true
|
||||
for built-ins AND any non-built-in so typed skill/quick commands run);
|
||||
`isDesktopSlashSuggestion(name)` gates **discovery** — used by BOTH completion paths in
|
||||
`app/chat/composer/hooks/use-slash-completions.ts` and by `filterDesktopCommandsCatalog`;
|
||||
|
||||
@@ -155,6 +155,15 @@ export function capabilityScoped(scope?: ProfileScope): { connectionId?: string;
|
||||
return { ...profileScoped(scope), ...connectionScoped() }
|
||||
}
|
||||
|
||||
/** Spawn priority for a REST call that may cold-start a pooled backend. An
|
||||
* explicit scope is a user pointing a scope selector (Settings "Applies to",
|
||||
* Capabilities) at another profile — a visible action that may take the
|
||||
* pool's reserved foreground slot (#111651). The ambient path stays untagged,
|
||||
* main's background default, so hydration cannot consume that slot. */
|
||||
export function scopedDialPriority(scope?: ProfileScope): { priority?: 'foreground' } {
|
||||
return scope == null ? {} : { priority: 'foreground' }
|
||||
}
|
||||
|
||||
/** Stable cache-key for a capability scope: `profile` for the ambient/legacy
|
||||
* path, `connectionId::profile` for ANY explicit pin — `local` included. An
|
||||
* explicit "This device" pick and the ambient path are no longer guaranteed
|
||||
|
||||
@@ -14,7 +14,14 @@ import type {
|
||||
StatusResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
|
||||
import {
|
||||
capabilityScoped,
|
||||
hermesApi,
|
||||
type ProfileScope,
|
||||
profileScoped,
|
||||
scopedDialPriority,
|
||||
STARTUP_REQUEST_TIMEOUT_MS
|
||||
} from './client'
|
||||
|
||||
export function getStatus(): Promise<StatusResponse> {
|
||||
return hermesApi<StatusResponse>({
|
||||
@@ -74,6 +81,7 @@ export function getHermesConfigRecord(
|
||||
): Promise<HermesConfigRecord> {
|
||||
return window.hermesDesktop.api<HermesConfigRecord>({
|
||||
...capabilityScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: includeDefaults ? '/api/config' : '/api/config?include_defaults=false'
|
||||
})
|
||||
}
|
||||
@@ -89,6 +97,7 @@ export function getHermesConfigDefaults(): Promise<HermesConfigRecord> {
|
||||
export function getHermesConfigSchema(profile?: null | string): Promise<ConfigSchemaResponse> {
|
||||
return hermesApi<ConfigSchemaResponse>({
|
||||
...profileScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: '/api/config/schema'
|
||||
})
|
||||
}
|
||||
@@ -100,6 +109,7 @@ export function saveHermesConfig(
|
||||
): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: preserveLanguage ? '/api/config?preserve_language=true' : '/api/config',
|
||||
method: 'PUT',
|
||||
body: { config }
|
||||
@@ -112,6 +122,7 @@ export function saveHermesConfig(
|
||||
export function saveHermesConfigRecord(config: HermesConfigRecord, profile?: ProfileScope): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...capabilityScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: '/api/config',
|
||||
method: 'PUT',
|
||||
body: { config }
|
||||
@@ -121,6 +132,7 @@ export function saveHermesConfigRecord(config: HermesConfigRecord, profile?: Pro
|
||||
export function getEnvVars(profile?: null | string): Promise<Record<string, EnvVarInfo>> {
|
||||
return hermesApi<Record<string, EnvVarInfo>>({
|
||||
...profileScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: '/api/env'
|
||||
})
|
||||
}
|
||||
@@ -128,6 +140,7 @@ export function getEnvVars(profile?: null | string): Promise<Record<string, EnvV
|
||||
export function setEnvVar(key: string, value: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...capabilityScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: '/api/env',
|
||||
method: 'PUT',
|
||||
body: { key, value }
|
||||
@@ -137,6 +150,7 @@ export function setEnvVar(key: string, value: string, profile?: ProfileScope): P
|
||||
export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...capabilityScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: '/api/env',
|
||||
method: 'DELETE',
|
||||
body: { key }
|
||||
@@ -146,6 +160,7 @@ export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok:
|
||||
export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key: string; value: string }> {
|
||||
return window.hermesDesktop.api<{ key: string; value: string }>({
|
||||
...capabilityScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: '/api/env/reveal',
|
||||
method: 'POST',
|
||||
body: { key }
|
||||
@@ -208,6 +223,7 @@ export function deleteCustomEndpoint(id: string): Promise<CustomEndpointsRespons
|
||||
export function listOAuthProviders(profile?: null | string): Promise<OAuthProvidersResponse> {
|
||||
return hermesApi<OAuthProvidersResponse>({
|
||||
...profileScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: '/api/providers/oauth'
|
||||
})
|
||||
}
|
||||
@@ -218,6 +234,7 @@ export function disconnectOAuthProvider(
|
||||
): Promise<{ ok: boolean; provider: string }> {
|
||||
return hermesApi<{ ok: boolean; provider: string }>({
|
||||
...profileScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
@@ -226,6 +243,7 @@ export function disconnectOAuthProvider(
|
||||
export function startOAuthLogin(providerId: string, profile?: ProfileScope): Promise<OAuthStartResponse> {
|
||||
return window.hermesDesktop.api<OAuthStartResponse>({
|
||||
...capabilityScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
|
||||
method: 'POST',
|
||||
body: {}
|
||||
@@ -240,6 +258,7 @@ export function submitOAuthCode(
|
||||
): Promise<OAuthSubmitResponse> {
|
||||
return hermesApi<OAuthSubmitResponse>({
|
||||
...profileScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
|
||||
method: 'POST',
|
||||
body: { session_id: sessionId, code }
|
||||
@@ -253,6 +272,7 @@ export function pollOAuthSession(
|
||||
): Promise<OAuthPollResponse> {
|
||||
return window.hermesDesktop.api<OAuthPollResponse>({
|
||||
...capabilityScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`
|
||||
})
|
||||
}
|
||||
@@ -260,6 +280,7 @@ export function pollOAuthSession(
|
||||
export function cancelOAuthSession(sessionId: string, profile?: null | string): Promise<{ ok: boolean }> {
|
||||
return hermesApi<{ ok: boolean }>({
|
||||
...profileScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { McpCatalogResponse, McpServerSummary } from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, scopedDialPriority } from './client'
|
||||
|
||||
export interface McpTestResult {
|
||||
ok: boolean
|
||||
@@ -117,6 +117,7 @@ export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ o
|
||||
export function getMcpCatalog(profile?: ProfileScope): Promise<McpCatalogResponse> {
|
||||
return window.hermesDesktop.api<McpCatalogResponse>({
|
||||
...capabilityScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: '/api/mcp/catalog'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ModelOptionsResult } from '@hermes/shared'
|
||||
|
||||
import type {
|
||||
AnalyticsResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
MoaConfigResponse,
|
||||
ModelAssignmentRequest,
|
||||
ModelAssignmentResponse,
|
||||
ModelInfoResponse,
|
||||
ModelOptionsResponse
|
||||
ModelInfoResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
|
||||
@@ -32,7 +33,7 @@ export function getGlobalModelOptions(
|
||||
explicitOnly?: boolean
|
||||
},
|
||||
profile?: null | string
|
||||
): Promise<ModelOptionsResponse> {
|
||||
): Promise<ModelOptionsResult> {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (opts?.refresh) {
|
||||
@@ -47,7 +48,7 @@ export function getGlobalModelOptions(
|
||||
params.set('explicit_only', '1')
|
||||
}
|
||||
|
||||
return hermesApi<ModelOptionsResponse>({
|
||||
return hermesApi<ModelOptionsResult>({
|
||||
...profileScoped(profile),
|
||||
path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { reconnectBackoffDelayMs } from '@hermes/shared'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
|
||||
import { RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
|
||||
|
||||
import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client'
|
||||
|
||||
@@ -8,8 +8,9 @@ import type {
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, STARTUP_REQUEST_TIMEOUT_MS } from './client'
|
||||
|
||||
export function getProfiles(): Promise<ProfilesResponse> {
|
||||
export function getProfiles(scope?: ProfileScope): Promise<ProfilesResponse> {
|
||||
return hermesApi<ProfilesResponse>({
|
||||
...(scope === undefined ? {} : capabilityScoped(scope)),
|
||||
path: '/api/profiles',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
|
||||
@@ -9,11 +9,12 @@ import type {
|
||||
} from '@/types/hermes'
|
||||
import type { ActionResponse } from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, scopedDialPriority } from './client'
|
||||
|
||||
export function getSkills(profile?: ProfileScope): Promise<SkillInfo[]> {
|
||||
return window.hermesDesktop.api<SkillInfo[]>({
|
||||
...capabilityScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: '/api/skills'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ToolsetModelsResponse
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
|
||||
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, scopedDialPriority } from './client'
|
||||
|
||||
// The optional trailing `profile` on every capability fetcher below is the
|
||||
// Capabilities view's profile-scope override: it lets the Skills/Tools/MCP
|
||||
@@ -17,6 +17,7 @@ import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from '.
|
||||
export function getToolsets(profile?: ProfileScope): Promise<ToolsetInfo[]> {
|
||||
return window.hermesDesktop.api<ToolsetInfo[]>({
|
||||
...capabilityScoped(profile),
|
||||
...scopedDialPriority(profile),
|
||||
path: '/api/tools/toolsets'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { compactNumber } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
@@ -8,7 +9,6 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { compactNumber } from '@/lib/format'
|
||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
acceptsTriggerCompletion,
|
||||
implicitSlashAcceptIndex,
|
||||
isPendingDraftPersistCurrent,
|
||||
liveComposerDraft,
|
||||
type PendingDraftPersist,
|
||||
pickPlaceholder,
|
||||
shouldDisableComposerInput,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
slashCommandToken,
|
||||
type TriggerAcceptInput
|
||||
} from './composer-utils'
|
||||
import { normalizeComposerEditorDom, RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
const item = (group: string): Unstable_TriggerItem =>
|
||||
({ id: 'x', type: 'slash', label: 'x', metadata: { group } }) as unknown as Unstable_TriggerItem
|
||||
@@ -177,3 +179,38 @@ describe('isPendingDraftPersistCurrent (#54527 integrity guard)', () => {
|
||||
expect(isPendingDraftPersistCurrent(null, null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/** Real contentEditable, built the way `empty-composer.test.ts` builds one. */
|
||||
function editorWith(text: string): HTMLDivElement {
|
||||
const el = document.createElement('div')
|
||||
|
||||
el.dataset.slot = RICH_INPUT_SLOT
|
||||
el.contentEditable = 'true'
|
||||
el.append(document.createTextNode(text))
|
||||
normalizeComposerEditorDom(el)
|
||||
document.body.append(el)
|
||||
|
||||
return el
|
||||
}
|
||||
|
||||
// editorWith appends to the shared JSDOM body; empty it so the element does not
|
||||
// leak into other cases in this file.
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('liveComposerDraft (stale-mirror guard for the ArrowUp recall)', () => {
|
||||
it('reads the live editor text even when the mirror is still empty', () => {
|
||||
// The race this exists for: a keystroke or paste flushed only by the
|
||||
// coalesced rAF, so `draftRef.current` holds the pre-keystroke text while
|
||||
// the editor already holds what the user typed. The recall guard must see
|
||||
// the typed text, not the stale empty mirror.
|
||||
const editor = editorWith('just typed this')
|
||||
|
||||
expect(liveComposerDraft(editor, '')).toBe('just typed this')
|
||||
})
|
||||
|
||||
it('falls back to the mirror before the editor mounts', () => {
|
||||
expect(liveComposerDraft(null, 'mirrored draft')).toBe('mirrored draft')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SlashChipKind } from '@/components/assistant-ui/directive-text'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { setSessionPickerOpen } from '@/store/session'
|
||||
|
||||
import { composerPlainText } from './rich-editor'
|
||||
import type { TriggerState } from './text-utils'
|
||||
|
||||
export const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||
@@ -217,3 +218,15 @@ export function isPendingDraftPersistCurrent(
|
||||
): boolean {
|
||||
return pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer text a keystroke should decide from.
|
||||
*
|
||||
* `mirror` (the composer's draftRef) is refreshed by a coalesced per-frame
|
||||
* flush, so within a frame of a keystroke or paste it still holds the previous
|
||||
* text. A decision that can act on the draft — the sent-message recall guard
|
||||
* replaces the composer — has to read the live editor instead.
|
||||
*/
|
||||
export function liveComposerDraft(editor: HTMLElement | null | undefined, mirror: string): string {
|
||||
return editor ? composerPlainText(editor) : mirror
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function ContextMenu({
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<Tip label={state.tools.label} side="top">
|
||||
<Tip label={state.tools.label} side="left">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
|
||||
@@ -62,19 +62,28 @@ afterEach(() => {
|
||||
$hudMode.set(false)
|
||||
})
|
||||
|
||||
// The HUD is a Spotlight bar a few hundred pixels wide: the four voice
|
||||
// controls fold into one menu there, and the way out of HUD mode joins the
|
||||
// row instead of floating above the bar in a reserved strip. The docked
|
||||
// composer keeps every control inline and shows no exit.
|
||||
// The HUD is a Spotlight bar a few hundred pixels wide: the voice controls
|
||||
// fold into one menu there, and the way out of HUD mode joins the row instead
|
||||
// of floating above the bar in a reserved strip. The docked composer keeps the
|
||||
// mic inline, with the other voice toggles fanned out of it on hover, and
|
||||
// shows no exit.
|
||||
describe('HUD mode', () => {
|
||||
it('keeps the voice controls inline and offers no exit in the docked composer', () => {
|
||||
it('keeps the mic inline, fans the toggles on hover, and offers no exit in the docked composer', async () => {
|
||||
renderControls()
|
||||
|
||||
expect(screen.getByLabelText('Voice dictation')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Read replies aloud')).toBeTruthy()
|
||||
const mic = screen.getByLabelText('Voice dictation')
|
||||
|
||||
expect(mic).toBeTruthy()
|
||||
expect(screen.queryByLabelText('Read replies aloud')).toBeNull()
|
||||
|
||||
fireEvent.pointerEnter(mic.parentElement!)
|
||||
|
||||
expect(await screen.findByLabelText('Read replies aloud')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Wake word "hey hermes"')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('Exit HUD mode')).toBeNull()
|
||||
expect(screen.queryByLabelText('Reset HUD size and position')).toBeNull()
|
||||
expect(screen.queryByLabelText('Voice')).toBeNull()
|
||||
// No folded menu trigger — the fan's group shares the "Voice" name.
|
||||
expect(screen.queryByRole('button', { name: 'Voice' })).toBeNull()
|
||||
})
|
||||
|
||||
it('folds them into one menu and offers the way out in the HUD', () => {
|
||||
@@ -173,38 +182,37 @@ describe('wake-word ear visibility', () => {
|
||||
resetWakeWordState()
|
||||
})
|
||||
|
||||
it('stays mounted during a busy agent turn', () => {
|
||||
// The ear lives in the mic's fan now: hover the mic to reach it.
|
||||
const findEar = async () => {
|
||||
fireEvent.pointerEnter(screen.getByLabelText('Voice dictation').parentElement!)
|
||||
|
||||
return screen.findByLabelText('Wake word "hey hermes"')
|
||||
}
|
||||
|
||||
it('stays reachable during a busy agent turn', async () => {
|
||||
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
|
||||
renderControls({ busy: true, busyAction: 'stop' })
|
||||
|
||||
expect(screen.getByLabelText('Wake word: "hey hermes" — listening')).toBeTruthy()
|
||||
expect((await findEar()).getAttribute('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('stays mounted (enabled in config) even when a start was refused', () => {
|
||||
it('stays reachable (enabled in config) even when a start was refused', async () => {
|
||||
applyWakeStatus({ available: true, enabled: true, listening: false, phrase: 'hey hermes' })
|
||||
// Transient refusal marks available false but enabled keeps it mounted.
|
||||
applyWakeStartResult({ hint: 'mic busy', reason: 'unavailable', started: false })
|
||||
renderControls()
|
||||
|
||||
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
|
||||
expect((await findEar()).getAttribute('aria-pressed')).toBe('false')
|
||||
})
|
||||
|
||||
it('stays visible (never hides) even when unavailable and not enabled', () => {
|
||||
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
|
||||
renderControls()
|
||||
|
||||
// The ear ALWAYS shows so the user can click to enable; a failed start
|
||||
// surfaces its reason in the tooltip rather than hiding the control.
|
||||
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('surfaces the backend refusal reason in the tooltip, still visible', () => {
|
||||
it('stays reachable (never hides) even when unavailable and not enabled', async () => {
|
||||
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
|
||||
applyWakeStartResult({ hint: 'run `hermes tools` (Voice section)', reason: 'unavailable', started: false })
|
||||
renderControls()
|
||||
|
||||
const ear = screen.getByLabelText('Wake word: "hey hermes" — off')
|
||||
expect(ear).toBeTruthy()
|
||||
// The ear ALWAYS shows so the user can click to enable; a refused start
|
||||
// never hides the control.
|
||||
expect(((await findEar()) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('shows a disabled paused ear inside the voice-conversation pill', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Ear, EarOff, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { Ear, EarOff, iconSize, Layers3, Loader2, Square } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hudMode, closeHud, resetHudLayout } from '@/store/hud'
|
||||
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
|
||||
@@ -14,8 +14,10 @@ import { ApprovalModePill } from './approval-mode-pill'
|
||||
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
import { ModelPill } from './model-pill'
|
||||
import { ReasoningPill } from './reasoning-pill'
|
||||
import { StartVoiceButton } from './start-voice-button'
|
||||
import type { ChatBarState, VoiceStatus } from './types'
|
||||
import { VoiceFan } from './voice-fan'
|
||||
import { VoiceMenu } from './voice-menu'
|
||||
|
||||
// Re-exported: `context-menu.tsx` and other row neighbours have always reached
|
||||
@@ -100,11 +102,15 @@ export function ComposerControls({
|
||||
voiceStatus={voiceStatus}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
<WakeWordButton disabled={disabled} />
|
||||
</>
|
||||
// One mic in the row; hovering it fans the other voice toggles out of it.
|
||||
<VoiceFan
|
||||
autoSpeak={autoSpeak}
|
||||
disabled={disabled}
|
||||
onDictate={onDictate}
|
||||
onToggleAutoSpeak={onToggleAutoSpeak}
|
||||
state={state}
|
||||
voiceStatus={voiceStatus}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -112,12 +118,17 @@ export function ComposerControls({
|
||||
{minimal ? null : (
|
||||
<>
|
||||
<ApprovalModePill compact={compactModelPill} />
|
||||
{hideModelPill ? null : <ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />}
|
||||
{hideModelPill ? null : (
|
||||
<>
|
||||
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
|
||||
{compactModelPill ? null : <ReasoningPill disabled={disabled} model={state.model} />}
|
||||
</>
|
||||
)}
|
||||
{voiceControls}
|
||||
</>
|
||||
)}
|
||||
{showQueueButton ? (
|
||||
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
|
||||
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />} side="left">
|
||||
<Button
|
||||
aria-label={c.queueMessage}
|
||||
className={GHOST_ICON_BTN}
|
||||
@@ -142,6 +153,7 @@ export function ComposerControls({
|
||||
<TipKeybindLabel actionId="composer.send" text={c.send} />
|
||||
)
|
||||
}
|
||||
side="left"
|
||||
>
|
||||
<Button
|
||||
aria-label={showStop ? c.stop : c.send}
|
||||
@@ -174,7 +186,7 @@ function HudWindowButtons() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tip label={t.titlebar.resetHudLayout}>
|
||||
<Tip label={t.titlebar.resetHudLayout} side="left">
|
||||
<Button
|
||||
aria-label={t.titlebar.resetHudLayout}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0')}
|
||||
@@ -186,7 +198,7 @@ function HudWindowButtons() {
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={t.titlebar.exitHud}>
|
||||
<Tip label={t.titlebar.exitHud} side="left">
|
||||
<Button
|
||||
aria-label={t.titlebar.exitHud}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0')}
|
||||
@@ -232,7 +244,7 @@ function ConversationPill({
|
||||
{/* Keep the ear visible during voice chat — shown paused, since the
|
||||
conversation holds the mic (the one time wake must not listen). */}
|
||||
<WakeWordButton disabled={disabled} pausedForVoice />
|
||||
<Tip label={muted ? c.unmuteMic : c.muteMic}>
|
||||
<Tip label={muted ? c.unmuteMic : c.muteMic} side="left">
|
||||
<Button
|
||||
aria-label={muted ? c.unmuteMic : c.muteMic}
|
||||
aria-pressed={muted}
|
||||
@@ -312,35 +324,6 @@ function ConversationIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
// Pure-TTS toggle: type normally, but have every assistant reply read aloud —
|
||||
// no dictation, no full conversation loop. Filled/accent when on, mirroring the
|
||||
// muted-mic pressed state above. Persisted locally, independently of gateway TTS.
|
||||
function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disabled: boolean; onToggle: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const label = active ? c.stopSpeakingReplies : c.speakReplies
|
||||
|
||||
return (
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', active && ACTIVE_ICON_BTN)}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic(active ? 'close' : 'open')
|
||||
onToggle()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{active ? <Volume2 className={iconSize.sm} /> : <VolumeX className={iconSize.sm} />}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
// "Hey Hermes" wake-word toggle. ALWAYS rendered — the ear never hides. A
|
||||
// user must always be able to click it to turn passive listening on; if the
|
||||
// backend can't start (missing STT/TTS, deps still installing, no mic
|
||||
@@ -366,7 +349,7 @@ function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolea
|
||||
const tooltip = !pausedForVoice && wake.notice ? `${label} — ${wake.notice}` : label
|
||||
|
||||
return (
|
||||
<Tip label={tooltip}>
|
||||
<Tip label={tooltip} side="left">
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={wake.listening && !pausedForVoice}
|
||||
@@ -385,55 +368,3 @@ function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolea
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
function DictationButton({
|
||||
disabled,
|
||||
state,
|
||||
status,
|
||||
onToggle
|
||||
}: {
|
||||
disabled: boolean
|
||||
state: ChatBarState['voice']
|
||||
status: VoiceStatus
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const active = state.active || status !== 'idle'
|
||||
|
||||
const aria =
|
||||
status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation
|
||||
|
||||
return (
|
||||
<Tip label={aria}>
|
||||
<Button
|
||||
aria-label={aria}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
|
||||
status === 'recording' && ACTIVE_ICON_BTN,
|
||||
status === 'transcribing' && 'bg-primary/10 text-primary'
|
||||
)}
|
||||
data-active={active}
|
||||
disabled={disabled || !state.enabled || status === 'transcribing'}
|
||||
onClick={() => {
|
||||
triggerHaptic(active ? 'close' : 'open')
|
||||
onToggle()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{status === 'recording' ? (
|
||||
<Square className={cn('fill-current', iconSize.xs)} />
|
||||
) : status === 'transcribing' ? (
|
||||
<Loader2 className={cn('animate-spin', iconSize.sm)} />
|
||||
) : (
|
||||
<Codicon name="mic" size="0.875rem" />
|
||||
)}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
|
||||
import { useComposerScope } from '../scope'
|
||||
|
||||
interface UseComposerBranchOptions {
|
||||
clearDraft: () => void
|
||||
cwd: null | string | undefined
|
||||
draftRef: MutableRefObject<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch / worktree engine — the `CodingStatusRow` hand-offs. Each action opens
|
||||
* a fresh session anchored in a worktree carrying the current composer draft as
|
||||
* its first turn; clearing here means the draft travels to the new session
|
||||
* instead of getting stashed under this one. Backend coupling (cwd + the
|
||||
* projects store) is the only dependency; nothing about ChatBar's render.
|
||||
*/
|
||||
export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBranchOptions) {
|
||||
const scope = useComposerScope()
|
||||
|
||||
// Hand a worktree off to the controller: open a fresh session anchored there,
|
||||
// carrying the composer draft as its first turn. Clearing here means the draft
|
||||
// travels to the new session instead of getting stashed under this one.
|
||||
const openInWorktree = useCallback(
|
||||
(path: string) => {
|
||||
const text = draftRef.current
|
||||
clearDraft()
|
||||
scope.attachments.clear()
|
||||
requestStartWorkSession(path, text)
|
||||
},
|
||||
[clearDraft, draftRef]
|
||||
)
|
||||
|
||||
// Branch off into a NEW worktree (base = branch name, or current HEAD). A
|
||||
// create failure throws back to the row (which toasts) before we touch the
|
||||
// draft; a missing cwd / remote backend no-ops (the row hides the affordance).
|
||||
const handleBranchOff = useCallback(
|
||||
async (branch: string, base?: string) => {
|
||||
const repoPath = cwd?.trim()
|
||||
const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch }))
|
||||
|
||||
if (result) {
|
||||
openInWorktree(result.path)
|
||||
}
|
||||
},
|
||||
[cwd, openInWorktree]
|
||||
)
|
||||
|
||||
// Convert an EXISTING branch into a fresh worktree + session (no new branch).
|
||||
// Mirrors handleBranchOff's hand-off: create the worktree, then open a session
|
||||
// anchored there carrying the draft.
|
||||
const handleConvertBranch = useCallback(
|
||||
async (branch: string, path?: null | string, isDefault?: boolean) => {
|
||||
if (path?.trim()) {
|
||||
openInWorktree(path)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const repoPath = cwd?.trim()
|
||||
|
||||
if (repoPath && isDefault) {
|
||||
await switchBranchInRepo(repoPath, branch)
|
||||
openInWorktree(repoPath)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch }))
|
||||
|
||||
if (result) {
|
||||
openInWorktree(result.path)
|
||||
}
|
||||
},
|
||||
[cwd, openInWorktree]
|
||||
)
|
||||
|
||||
const handleListBranches = useCallback(async () => {
|
||||
const repoPath = cwd?.trim()
|
||||
|
||||
return repoPath ? listRepoBranches(repoPath) : []
|
||||
}, [cwd])
|
||||
|
||||
const handleSwitchBranch = useCallback(
|
||||
async (branch: string) => {
|
||||
const repoPath = cwd?.trim()
|
||||
|
||||
if (repoPath) {
|
||||
await switchBranchInRepo(repoPath, branch)
|
||||
}
|
||||
},
|
||||
[cwd]
|
||||
)
|
||||
|
||||
return { handleBranchOff, handleConvertBranch, handleListBranches, handleSwitchBranch, openInWorktree }
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import '@/store/suggestion-providers/mcp'
|
||||
import '@/store/suggestion-providers/skill'
|
||||
|
||||
import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
|
||||
import { SLASH_COMMAND_RE } from '@hermes/shared'
|
||||
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import {
|
||||
type ComposerAttachment,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility'
|
||||
import { $clarifyRequests } from '@/store/clarify'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { clearQueuedPrompts, getQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import {
|
||||
clearAllPrompts,
|
||||
hasBlockingPromptRequest,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
setSecretRequest,
|
||||
setSudoRequest
|
||||
} from '@/store/prompts'
|
||||
import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests'
|
||||
|
||||
import { type ComposerTarget, requestComposerSubmit } from '../focus'
|
||||
import { ComposerScopeProvider, ComposerSurfaceProvider, MAIN_COMPOSER_SCOPE } from '../scope'
|
||||
@@ -457,26 +457,30 @@ describe('useComposerSubmit busy-turn routing', () => {
|
||||
})
|
||||
|
||||
describe('useComposerSubmit with a clarify parked on the session', () => {
|
||||
const gatewayRequest = vi.fn(async () => ({ ok: true }))
|
||||
// The clarify is a live server→client request: skipping it answers that
|
||||
// request frame (`{ answer: '' }`), not a `clarify.respond` RPC.
|
||||
const respond = vi.fn()
|
||||
|
||||
const parkClarify = (sessionId: string) => {
|
||||
const requestId = `req-${sessionId}`
|
||||
|
||||
rememberServerRequest({ fail: vi.fn(), id: requestId, method: 'clarify', params: {}, respond })
|
||||
$clarifyRequests.set({
|
||||
[sessionId]: {
|
||||
requestId: `req-${sessionId}`,
|
||||
requestId,
|
||||
question: 'which one?',
|
||||
choices: ['a', 'b'],
|
||||
multiSelect: false,
|
||||
sessionId
|
||||
}
|
||||
})
|
||||
$gateway.set({ request: gatewayRequest } as unknown as ReturnType<typeof $gateway.get>)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
gatewayRequest.mockClear()
|
||||
respond.mockClear()
|
||||
resetServerRequestsForTests()
|
||||
$clarifyRequests.set({})
|
||||
$gateway.set(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -488,16 +492,12 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', {
|
||||
request_id: 'req-runtime-session',
|
||||
answer: ''
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(respond).toHaveBeenCalledWith({ answer: '' }))
|
||||
await waitFor(() =>
|
||||
expect(onSubmit).toHaveBeenCalledWith('actually do this instead', expect.objectContaining({ attachments: [] }))
|
||||
)
|
||||
expect($clarifyRequests.get()['runtime-session']).toBeUndefined()
|
||||
expect(hasOpenServerRequest('req-runtime-session')).toBe(false)
|
||||
})
|
||||
|
||||
it('skips the question before steering a busy turn', async () => {
|
||||
@@ -509,7 +509,7 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course'))
|
||||
expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-runtime-session', answer: '' })
|
||||
expect(respond).toHaveBeenCalledWith({ answer: '' })
|
||||
})
|
||||
|
||||
it('leaves the question alone for an empty Enter (Stop, not an answer)', () => {
|
||||
@@ -520,7 +520,8 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
expect(gatewayRequest).not.toHaveBeenCalled()
|
||||
expect(respond).not.toHaveBeenCalled()
|
||||
expect(hasOpenServerRequest('req-runtime-session')).toBe(true)
|
||||
expect($clarifyRequests.get()['runtime-session']).toBeDefined()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -534,7 +535,8 @@ describe('useComposerSubmit with a clarify parked on the session', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
||||
expect(gatewayRequest).not.toHaveBeenCalled()
|
||||
expect(respond).not.toHaveBeenCalled()
|
||||
expect(hasOpenServerRequest('req-other-session')).toBe(true)
|
||||
expect($clarifyRequests.get()['other-session']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { SLASH_COMMAND_RE } from '@hermes/shared'
|
||||
import { type RefObject, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify'
|
||||
import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
|
||||
import { hasMcpSetupRequest, skipMcpSetupRequest } from '@/store/mcp-setup'
|
||||
import { hasConnectionRequest, skipConnectionRequest } from '@/store/connection-request'
|
||||
import { hasBlockingPromptRequest } from '@/store/prompts'
|
||||
|
||||
import { cloneAttachments, type QueueEditState } from '../composer-utils'
|
||||
@@ -234,10 +234,9 @@ export function useComposerSubmit({
|
||||
void skipClarifyRequest(sessionId)
|
||||
}
|
||||
|
||||
// Same deal for a pending MCP setup card: the agent is blocked on
|
||||
// mcp.setup.respond, so a typed message declines the card and rides on.
|
||||
if (payloadPresent && !queueEdit && hasMcpSetupRequest(sessionId)) {
|
||||
void skipMcpSetupRequest(sessionId)
|
||||
// Same for a pending connection card: typing declines every target.
|
||||
if (payloadPresent && !queueEdit && hasConnectionRequest(sessionId)) {
|
||||
void skipConnectionRequest(sessionId)
|
||||
}
|
||||
|
||||
// Approval / sudo / secret prompts also park the turn inside a tool batch,
|
||||
|
||||
@@ -33,7 +33,11 @@ interface MicRecorderHandle {
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
|
||||
/** Recorder + live-start mic failures → the same friendly copy: a DOMException
|
||||
* name is mapped, an unrecognized DOMException falls back to the generic start
|
||||
* copy, and anything else keeps its own message (non-mic failures must not be
|
||||
* mislabeled as microphone problems). */
|
||||
export function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
|
||||
const name = error instanceof DOMException ? error.name : ''
|
||||
|
||||
if (name === 'NotAllowedError' || name === 'SecurityError') {
|
||||
@@ -52,6 +56,10 @@ function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
|
||||
return new Error(copy.microphoneConstraintsUnsupported)
|
||||
}
|
||||
|
||||
if (error instanceof DOMException) {
|
||||
return new Error(copy.microphoneStartFailed)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error
|
||||
}
|
||||
|
||||
@@ -1,9 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { chunkForCommentary, toLiveHistory } from '@/lib/voice-live'
|
||||
import { en } from '@/i18n/en'
|
||||
import { chunkForCommentary, toLiveHistory, type VoiceLiveHandlers } from '@/lib/voice-live'
|
||||
import { $notifications, clearNotifications } from '@/store/notifications'
|
||||
|
||||
import { delegationPrompt } from './use-voice-live-conversation'
|
||||
import { delegationPrompt, useVoiceLiveConversation } from './use-voice-live-conversation'
|
||||
|
||||
// The live-voice toasts must not show machine strings — neither the wire close
|
||||
// reasons (`connection_lost`, `closed`) nor the raw `DOMException` text a denied
|
||||
// `getUserMedia` throws (#111987).
|
||||
//
|
||||
// The transport is the only seam in the live hook, so the fake session records
|
||||
// the handlers it registers (tests drive the close/error paths directly) and
|
||||
// lets `start` reject with exactly what the real `getUserMedia` would throw.
|
||||
const transport = vi.hoisted(() => ({ failure: null as unknown, handlers: [] as VoiceLiveHandlers[] }))
|
||||
|
||||
vi.mock('@/lib/voice-live', async importOriginal => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
|
||||
return {
|
||||
...actual,
|
||||
VoiceLiveSession: class {
|
||||
close = vi.fn()
|
||||
instruct = vi.fn()
|
||||
setMuted = vi.fn()
|
||||
speak = vi.fn()
|
||||
think = vi.fn()
|
||||
|
||||
constructor(handlers: VoiceLiveHandlers) {
|
||||
transport.handlers.push(handlers)
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (transport.failure) {
|
||||
throw transport.failure
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const voice = en.notifications.voice
|
||||
|
||||
function mountLive() {
|
||||
return renderHook(() =>
|
||||
useVoiceLiveConversation({
|
||||
busy: false,
|
||||
consumePendingResponse: vi.fn(),
|
||||
enabled: true,
|
||||
onSubmit: vi.fn(),
|
||||
pendingResponse: () => null,
|
||||
seedHistory: () => []
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Mount, start, and hand back the handlers the started session registered. */
|
||||
async function openSession(): Promise<VoiceLiveHandlers> {
|
||||
const hook = mountLive()
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.start()
|
||||
})
|
||||
|
||||
return transport.handlers.at(-1) as VoiceLiveHandlers
|
||||
}
|
||||
|
||||
/** Start a session whose transport fails the way the real one can. */
|
||||
async function failedStart(failure: unknown): Promise<void> {
|
||||
transport.failure = failure
|
||||
const hook = mountLive()
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.start()
|
||||
})
|
||||
}
|
||||
|
||||
function resetToasts() {
|
||||
clearNotifications()
|
||||
transport.failure = null
|
||||
transport.handlers.length = 0
|
||||
}
|
||||
|
||||
describe('Voice-live toast copy', () => {
|
||||
beforeEach(resetToasts)
|
||||
afterEach(cleanup)
|
||||
|
||||
it('names our own close reasons in copy and passes server-sent reasons through verbatim', async () => {
|
||||
let handlers = await openSession()
|
||||
|
||||
act(() => {
|
||||
handlers.onClosed('connection_lost', 127)
|
||||
})
|
||||
|
||||
expect($notifications.get()[0].title).toBe(voice.liveEnded)
|
||||
expect($notifications.get()[0].message).toBe(`${voice.liveEndedConnectionLost} (127s)`)
|
||||
|
||||
cleanup()
|
||||
resetToasts()
|
||||
handlers = await openSession()
|
||||
|
||||
act(() => {
|
||||
handlers.onClosed('quota_exhausted', 12)
|
||||
})
|
||||
|
||||
// Server strings are unbounded: no mapping, no redaction claim.
|
||||
expect($notifications.get()[0].message).toBe('quota_exhausted (12s)')
|
||||
})
|
||||
|
||||
it('maps a getUserMedia DOMException to the recorder mic copy and leaves non-mic failures alone', async () => {
|
||||
await failedStart(new DOMException('The request is not allowed by the user agent.', 'NotAllowedError'))
|
||||
|
||||
expect($notifications.get()[0].title).toBe(voice.couldNotStartSession)
|
||||
expect($notifications.get()[0].message).toBe(voice.microphonePermissionDenied)
|
||||
|
||||
cleanup()
|
||||
resetToasts()
|
||||
await failedStart(new Error('Missing local SDP offer'))
|
||||
|
||||
expect($notifications.get()[0].message).toBe('Missing local SDP offer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GPT-Live delegation → Hermes turn', () => {
|
||||
it('sends the latest user words as the turn and the exchange as model-only context', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type LiveHistoryMessage, type LiveTranscriptFragment, VoiceLiveSession
|
||||
import { isVoiceStopCommand } from '@/lib/voice-stop-word'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { micError } from './use-mic-recorder'
|
||||
import type { ConversationStatus } from './use-voice-conversation'
|
||||
|
||||
/** How long an accepted delegation may sit before the gateway shows the turn running. */
|
||||
@@ -67,6 +68,29 @@ export function delegationPrompt(context: LiveTranscriptFragment[]): { context:
|
||||
return { context: transcript, prompt: prompt || transcript.slice(-400) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of the session-end toast. `connection_lost` and `closed` are our own
|
||||
* machine reasons (`lib/voice-live.ts`) and get i18n copy; so does a blank
|
||||
* reason, which has no wording of its own. Any other reason is server-sent and
|
||||
* unbounded, so it passes through verbatim (issue #111987 — no redaction claim
|
||||
* for vendor strings).
|
||||
*/
|
||||
export function liveEndedMessage(
|
||||
reason: string,
|
||||
usageSeconds: null | number,
|
||||
copy: { liveEndedClosed: string; liveEndedConnectionLost: string }
|
||||
): string {
|
||||
let text = reason?.trim() ?? ''
|
||||
|
||||
if (text === 'connection_lost') {
|
||||
text = copy.liveEndedConnectionLost
|
||||
} else if (text === 'closed' || !text) {
|
||||
text = copy.liveEndedClosed
|
||||
}
|
||||
|
||||
return usageSeconds != null ? `${text} (${Math.round(usageSeconds)}s)` : text
|
||||
}
|
||||
|
||||
/**
|
||||
* GPT-Live conversation engine — same public shape as `useVoiceConversation`
|
||||
* so the composer can mount either from `voice.voice_chat_mode`.
|
||||
@@ -252,7 +276,7 @@ export function useVoiceLiveConversation({
|
||||
if (reason !== 'close_requested') {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
message: usageSeconds != null ? `${reason} (${Math.round(usageSeconds)}s)` : reason,
|
||||
message: liveEndedMessage(reason, usageSeconds, voiceCopy),
|
||||
title: voiceCopy.liveEnded
|
||||
})
|
||||
latest.current.onFatalError?.()
|
||||
@@ -330,19 +354,14 @@ export function useVoiceLiveConversation({
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(error, voiceCopy.couldNotStartSession)
|
||||
// Only a mic DOMException gets the recorder's copy: this catch also
|
||||
// takes non-mic start failures ('GPT-Live session already started',
|
||||
// 'Missing local SDP offer', API errors) — those keep their own message.
|
||||
notifyError(error instanceof DOMException ? micError(error, voiceCopy) : error, voiceCopy.couldNotStartSession)
|
||||
setStatus('idle')
|
||||
latest.current.onFatalError?.()
|
||||
}
|
||||
}, [
|
||||
end,
|
||||
refreshStatus,
|
||||
setDelegation,
|
||||
voiceCopy.couldNotStartSession,
|
||||
voiceCopy.liveDelegationFailed,
|
||||
voiceCopy.liveEnded,
|
||||
voiceCopy.liveError
|
||||
])
|
||||
}, [end, refreshStatus, setDelegation, voiceCopy])
|
||||
|
||||
// Drive the reply back into the voice: stream commentary as Hermes writes
|
||||
// it (sentence-chunked), quiet tool progress as thinking appends, and clear
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PR_COMMENT_URL_RE } from '@/lib/chat-runtime'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useStoreSelector, useStoresSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { interceptsTypedVoiceStop } from '@/lib/voice-stop-word'
|
||||
import { sessionCompacting } from '@/store/compaction'
|
||||
@@ -24,8 +25,10 @@ import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
|
||||
import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $hudMode } from '@/store/hud'
|
||||
import { sessionBlockingPrompt } from '@/store/prompts'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { $botChatSessionIds, $sessionStates, $sessionTiles, isBotChatSession } from '@/store/session-states'
|
||||
import { $threadScrolledUpBySession } from '@/store/thread-scroll'
|
||||
import { $autoSpeakReplies } from '@/store/voice-prefs'
|
||||
import { useTheme } from '@/themes'
|
||||
|
||||
@@ -34,6 +37,7 @@ import {
|
||||
acceptsTriggerCompletion,
|
||||
COMPOSER_FADE_BACKGROUND,
|
||||
implicitSlashAcceptIndex,
|
||||
liveComposerDraft,
|
||||
type QueueEditState,
|
||||
shouldDisableComposerInput,
|
||||
slashArgStage
|
||||
@@ -46,6 +50,7 @@ import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-aff
|
||||
import { markActiveComposer, onComposerAttachImagesRequest } from './focus'
|
||||
import { HelpHint } from './help-hint'
|
||||
import { useAtCompletions } from './hooks/use-at-completions'
|
||||
import { useComposerBranch } from './hooks/use-composer-branch'
|
||||
import { useComposerDraft } from './hooks/use-composer-draft'
|
||||
import { useComposerDrop } from './hooks/use-composer-drop'
|
||||
import { useComposerEscCancel } from './hooks/use-composer-esc-cancel'
|
||||
@@ -75,15 +80,22 @@ import {
|
||||
normalizeComposerEditorDom,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { useComposerScope } from './scope'
|
||||
import { useComposerScope, useComposerSurfaceId } from './scope'
|
||||
import { ComposerStatusStack } from './status-stack'
|
||||
import { CodingStatusRow } from './status-stack/coding-row'
|
||||
import { SuggestionPills } from './suggestion-pills'
|
||||
import { extractClipboardImageBlobs, openDirectiveScope } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
import { isRedoShortcut, isUndoShortcut } from './undo-history'
|
||||
import { UrlDialog } from './url-dialog'
|
||||
import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs'
|
||||
import {
|
||||
chipTypedUrlOnSpace,
|
||||
linkifyUrls,
|
||||
markdownLinkFor,
|
||||
resolveExactLinkPaste,
|
||||
selectionLinkLabel
|
||||
} from './url-refs'
|
||||
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
|
||||
export function ChatBar({
|
||||
@@ -163,7 +175,13 @@ export function ChatBar({
|
||||
const scope = useComposerScope()
|
||||
const attachments = useStore(scope.attachments.$attachments)
|
||||
const compacting = useStore(useMemo(() => sessionCompacting(sessionId ?? null), [sessionId]))
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const surfaceId = useComposerSurfaceId()
|
||||
const scrollSessionId = sessionId ?? surfaceId
|
||||
|
||||
const scrolledUp = useStoreSelector($threadScrolledUpBySession, map =>
|
||||
Boolean(scrollSessionId && map[scrollSessionId])
|
||||
)
|
||||
|
||||
const autoSpeak = useStore($autoSpeakReplies)
|
||||
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
|
||||
// not interrupt it — there's nothing actively running to stop, and stopping
|
||||
@@ -559,6 +577,24 @@ export function ChatBar({
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
// Pasting exactly one link while composer text is selected turns that text
|
||||
// into a markdown link instead of replacing it — the behavior every rich
|
||||
// editor ships (ported from block/buzz#6684). Selections that span chips
|
||||
// or lines fall through to the normal replace-with-chip path.
|
||||
const exactLink = resolveExactLinkPaste(pastedText)
|
||||
|
||||
if (exactLink) {
|
||||
const label = selectionLinkLabel(event.currentTarget)
|
||||
|
||||
if (label) {
|
||||
recordUndoPoint()
|
||||
insertComposerContentsAtCaret(event.currentTarget, markdownLinkFor(label, exactLink))
|
||||
scheduleFlushEditorToDraft(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// A paste past the large-paste threshold becomes a `.txt` attachment chip
|
||||
// instead of flooding the composer.
|
||||
// The instruction the user types stays in the input; the pasted source
|
||||
@@ -817,7 +853,9 @@ export function ChatBar({
|
||||
// place) then sent-message history. The history ring is derived from live
|
||||
// session messages each press — single source of truth, no mirror.
|
||||
if (event.key === 'ArrowUp') {
|
||||
const currentDraft = draftRef.current
|
||||
// Decide from the live editor: the mirror is a frame behind typing or a
|
||||
// paste, and this branch can replace what the user just wrote.
|
||||
const currentDraft = liveComposerDraft(editorRef.current, draftRef.current)
|
||||
|
||||
// Editing a queued turn → walk to the older entry.
|
||||
if (queueEdit && stepQueuedEdit(-1)) {
|
||||
@@ -891,7 +929,7 @@ export function ChatBar({
|
||||
if (busy && !disabled) {
|
||||
// As with plain Enter, source the just-typed content from the DOM so a
|
||||
// fast keypress cannot queue a stale draft.
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const editorText = liveComposerDraft(editorRef.current, draftRef.current)
|
||||
|
||||
if (editorText !== draftRef.current) {
|
||||
draftRef.current = editorText
|
||||
@@ -913,7 +951,7 @@ export function ChatBar({
|
||||
// Without the live read, a real message typed while prompts are queued
|
||||
// would drain the queue instead of sending. submitDraft() re-syncs and
|
||||
// sends the live editor text.
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const editorText = liveComposerDraft(editorRef.current, draftRef.current)
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (disabled) {
|
||||
@@ -981,6 +1019,19 @@ export function ChatBar({
|
||||
handleInputDrop
|
||||
} = useComposerDrop({ cwd, insertInlineRefs, onAttachDroppedItems, requestMainFocus })
|
||||
|
||||
// A bot chat is a companion conversation, not a working session, so it has no
|
||||
// repo to speak of — see the blank repoPath handed to CodingStatusRow below.
|
||||
// Three stores: the scope set records the answer, and resolving this runtime
|
||||
// id to the stored one it is filed under reads the other two.
|
||||
const botChat = useStoresSelector([$botChatSessionIds, $sessionStates, $sessionTiles], () =>
|
||||
isBotChatSession(sessionId)
|
||||
)
|
||||
|
||||
// Branch / worktree hand-offs (CodingStatusRow). Owns the worktree open +
|
||||
// branch-off/convert/list/switch actions; draft travels into the new session.
|
||||
const { handleBranchOff, handleConvertBranch, handleListBranches, handleSwitchBranch, openInWorktree } =
|
||||
useComposerBranch({ clearDraft, cwd, draftRef })
|
||||
|
||||
// Global Esc-to-cancel when the chat (not the composer input) has focus.
|
||||
// Same explicit-halt semantics as the Stop button: park the queue.
|
||||
useComposerEscCancel({ awaitingInput, busy, onCancel: haltRun, target: scope.target })
|
||||
@@ -1341,6 +1392,23 @@ export function ChatBar({
|
||||
composerSurfaceGlass
|
||||
)}
|
||||
/>
|
||||
{!guidedChat && (
|
||||
<CodingStatusRow
|
||||
onBranchOff={handleBranchOff}
|
||||
onConvertBranch={handleConvertBranch}
|
||||
onListBranches={handleListBranches}
|
||||
// A tile's rail reviews ITS worktree: pin the pane's scope to
|
||||
// this surface's cwd. Main keeps the classic follow-the-
|
||||
// active-session scope (null).
|
||||
onOpen={() => toggleReview(scope.target === 'main' ? null : (cwd ?? null), scope.target)}
|
||||
onOpenWorktree={openInWorktree}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
// Blank in a bot chat: the row hides itself without a repo,
|
||||
// and stops probing git / GitHub for a surface that has no
|
||||
// branch to show. Cheaper than a second composer.
|
||||
repoPath={botChat ? undefined : cwd}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
|
||||
@@ -38,11 +38,15 @@ export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: str
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.from(transfer.types || []).includes(pathsMime)) {
|
||||
const types = Array.from(transfer.types || [])
|
||||
|
||||
// A browser link drag carries `text/uri-list` (on Windows also a virtual
|
||||
// shortcut File); accept it so the link lands as an `@url:` chip.
|
||||
if (types.includes(pathsMime) || types.includes('text/uri-list')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.from(transfer.types || []).includes('Files')) {
|
||||
if (types.includes('Files')) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -50,6 +54,10 @@ export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: str
|
||||
}
|
||||
|
||||
export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null | undefined) {
|
||||
if (candidate.url) {
|
||||
return `@url:${formatRefValue(candidate.url)}`
|
||||
}
|
||||
|
||||
if (!candidate.path) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('ModelPill per-surface model label', () => {
|
||||
</SessionViewProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Sonnet · High')).toBeTruthy()
|
||||
expect(screen.getByText('Sonnet')).toBeTruthy()
|
||||
expect(screen.queryByText(/primary/i)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,9 +12,9 @@ import { releaseTypingFocus } from '@/components/ui/keyboard-first'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import { formatModelPillLabel } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session'
|
||||
import { $currentModelSource, setModelPickerOpen } from '@/store/session'
|
||||
|
||||
import { onComposerModelMenuRequest } from './focus'
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
@@ -57,9 +57,7 @@ export function ModelPill({
|
||||
const currentModel = model.model || viewModel
|
||||
const currentProvider = model.provider || viewProvider
|
||||
const fastMode = useStore(view.$fast)
|
||||
const reasoningEffort = useStore(view.$reasoningEffort)
|
||||
const modelSource = useStore($currentModelSource)
|
||||
const defaultEffort = useStore($defaultReasoningEffort)
|
||||
const runtimeId = useStore(view.$runtimeId)
|
||||
const [open, setOpen] = useState(false)
|
||||
const restoreSelection = useRef<(() => void) | null>(null)
|
||||
@@ -131,9 +129,7 @@ export function ModelPill({
|
||||
) : (
|
||||
<>
|
||||
{currentModel.trim() ? (
|
||||
<span className="truncate">
|
||||
{formatModelStatusLabel(currentModel, { defaultEffort, fastMode, reasoningEffort })}
|
||||
</span>
|
||||
<span className="truncate">{formatModelPillLabel(currentModel, { fastMode })}</span>
|
||||
) : (
|
||||
<GlyphSpinner className="opacity-50" spinner="braille" />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatBarState } from '@/app/chat/composer/types'
|
||||
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
|
||||
import { $defaultReasoningEffort } from '@/store/session'
|
||||
|
||||
import { ReasoningPill } from './reasoning-pill'
|
||||
|
||||
const modelState = (over: Partial<ChatBarState['model']> = {}): ChatBarState['model'] => ({
|
||||
canSwitch: true,
|
||||
model: 'gpt-6',
|
||||
provider: 'openai',
|
||||
reasoningMenuContent: <div>menu</div>,
|
||||
...over
|
||||
})
|
||||
|
||||
const tileView = (reasoningEffort: string): SessionView => ({
|
||||
kind: 'tile',
|
||||
$awaitingResponse: atom(false),
|
||||
$busy: atom(false),
|
||||
$cwd: atom(''),
|
||||
$fast: atom(false),
|
||||
$lastVisibleIsUser: atom(false),
|
||||
$messages: atom([]),
|
||||
$messagesEmpty: atom(true),
|
||||
$model: atom('tile/claude-sonnet'),
|
||||
$provider: atom('anthropic'),
|
||||
$reasoningEffort: atom(reasoningEffort),
|
||||
$runtimeId: atom('tile-runtime'),
|
||||
$storedId: atom('stored-tile'),
|
||||
$turnStartedAt: atom<number | null>(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$defaultReasoningEffort.set('')
|
||||
})
|
||||
|
||||
describe('ReasoningPill', () => {
|
||||
it("shows THIS surface's live effort, falling back to the profile default when the session has none", () => {
|
||||
$defaultReasoningEffort.set('high')
|
||||
|
||||
const { unmount } = render(
|
||||
<SessionViewProvider value={tileView('low')}>
|
||||
<ReasoningPill disabled={false} model={modelState()} />
|
||||
</SessionViewProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('reasoning-pill').textContent).toBe('Low')
|
||||
unmount()
|
||||
|
||||
render(
|
||||
<SessionViewProvider value={tileView('')}>
|
||||
<ReasoningPill disabled={false} model={modelState()} />
|
||||
</SessionViewProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('reasoning-pill').textContent).toBe('High')
|
||||
})
|
||||
|
||||
it('hides when the catalog says the model has no reasoning control, but not while that is unknown', () => {
|
||||
const { unmount } = render(
|
||||
<SessionViewProvider value={tileView('medium')}>
|
||||
<ReasoningPill disabled={false} model={modelState({ supportsReasoning: false })} />
|
||||
</SessionViewProvider>
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('reasoning-pill')).toBeNull()
|
||||
unmount()
|
||||
|
||||
render(
|
||||
<SessionViewProvider value={tileView('medium')}>
|
||||
<ReasoningPill disabled={false} model={modelState({ supportsReasoning: undefined })} />
|
||||
</SessionViewProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('reasoning-pill')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DEFAULT_REASONING_EFFORT } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { releaseTypingFocus } from '@/components/ui/keyboard-first'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { reasoningEffortLabel } from '@/lib/reasoning-effort'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $defaultReasoningEffort } from '@/store/session'
|
||||
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
const PILL = cn(
|
||||
'h-(--composer-control-size) shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
|
||||
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)
|
||||
|
||||
/**
|
||||
* Composer reasoning selector: the active model's effort level as its own
|
||||
* pill next to the model pill, opening the same Thinking / Fast / Effort rows
|
||||
* the catalog offers per model — without having to find the model's row and
|
||||
* hover its submenu. Hidden when the catalog says the model has no reasoning
|
||||
* control, and while there is no live menu (gateway closed).
|
||||
*
|
||||
* Reads THIS surface's SessionView (primary or tile), like the model pill.
|
||||
*/
|
||||
export function ReasoningPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
|
||||
const copy = useI18n().t.shell.modelOptions
|
||||
const view = useSessionView()
|
||||
const reasoningEffort = useStore(view.$reasoningEffort)
|
||||
const defaultEffort = useStore($defaultReasoningEffort)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (!model.reasoningMenuContent || model.supportsReasoning === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = reasoningEffortLabel(reasoningEffort || defaultEffort || DEFAULT_REASONING_EFFORT)
|
||||
const title = `${copy.effort}: ${label}`
|
||||
|
||||
// Closing the menu ends its claim on the keyboard: Radix restores focus to
|
||||
// this pill (a toolbar button), so without the release the Enter that
|
||||
// committed a level also swallows whatever you type next.
|
||||
const setMenuOpen = (next: boolean) => {
|
||||
setOpen(next)
|
||||
|
||||
if (!next) {
|
||||
releaseTypingFocus()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setMenuOpen} open={open}>
|
||||
<Tip label={title} side="top">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={title}
|
||||
className={PILL}
|
||||
data-testid="reasoning-pill"
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="w-52 p-0" side="top" sideOffset={8}>
|
||||
<ModelMenuCloseContext.Provider value={() => setMenuOpen(false)}>
|
||||
{model.reasoningMenuContent}
|
||||
</ModelMenuCloseContext.Provider>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function StartVoiceButton({
|
||||
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<Tip label={engine ? `${label} — ${engine}` : label}>
|
||||
<Tip label={engine ? `${label} — ${engine}` : label} side="left">
|
||||
<Button
|
||||
aria-label={label}
|
||||
className={cn(PRIMARY_ICON_BTN, engine && 'rounded-r-none')}
|
||||
@@ -50,7 +50,7 @@ export function StartVoiceButton({
|
||||
</Tip>
|
||||
{engine ? (
|
||||
<DropdownMenu>
|
||||
<Tip label={t.composer.voiceEngine}>
|
||||
<Tip label={t.composer.voiceEngine} side="left">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={t.composer.voiceEngine}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $notifications, clearNotifications } from '@/store/notifications'
|
||||
|
||||
vi.mock('@/store/coding-status', () => ({
|
||||
registerRepoStatusCwd: () => undefined,
|
||||
repoStatusForCwd: () =>
|
||||
atom({
|
||||
added: 12,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
branch: 'bb/hitbox',
|
||||
defaultBranch: 'main',
|
||||
detached: false,
|
||||
removed: 3,
|
||||
untracked: 0
|
||||
}),
|
||||
repoWorktreesForCwd: () => atom([])
|
||||
}))
|
||||
|
||||
const { CodingStatusRow } = await import('./coding-row')
|
||||
|
||||
describe('CodingStatusRow', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('opens the review pane from the branch and the diff counts, never the bar itself', () => {
|
||||
const onOpen = vi.fn()
|
||||
|
||||
const { container } = render(<CodingStatusRow onOpen={onOpen} repoPath="/repo" />)
|
||||
|
||||
const bar = container.querySelector<HTMLElement>('.coding-status-bar')
|
||||
|
||||
expect(bar).not.toBeNull()
|
||||
|
||||
fireEvent.click(bar!)
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByText('bb/hitbox'))
|
||||
expect(onOpen).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByText('12'))
|
||||
expect(onOpen).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('wraps the click targets without adding a layout box', () => {
|
||||
const { container } = render(<CodingStatusRow onOpen={() => undefined} repoPath="/repo" />)
|
||||
|
||||
// `display: contents` is what keeps the branch label and the counts direct
|
||||
// flex children of the row — the hit areas cost nothing visually.
|
||||
expect(screen.getByText('bb/hitbox').parentElement?.classList.contains('contents')).toBe(true)
|
||||
expect(screen.getByText('12').closest('button')?.classList.contains('contents')).toBe(true)
|
||||
// The glyph button fills the row's existing 3.5 leading slot exactly.
|
||||
expect(container.querySelector('button[class~="size-3.5"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('parks the copy glyph against the end of the path, not the end of the row', () => {
|
||||
render(<CodingStatusRow onOpen={() => undefined} repoPath="/Users/someone/www/repo" />)
|
||||
|
||||
const path = screen.getByText('~/www/repo')
|
||||
|
||||
// The path sizes to its content and the glyph is its immediate sibling, so
|
||||
// the pair reads as one unit. `flex-1` belongs to the wrapper (which holds
|
||||
// the row's slack open) — on the label it stretched the text and pushed the
|
||||
// glyph out to the kebab.
|
||||
expect(path.classList.contains('flex-1')).toBe(false)
|
||||
expect(path.parentElement?.classList.contains('flex-1')).toBe(true)
|
||||
expect(path.nextElementSibling?.tagName).toBe('BUTTON')
|
||||
})
|
||||
|
||||
it('copies the absolute cwd inline — checkmark feedback, no toast', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
clearNotifications()
|
||||
|
||||
render(<CodingStatusRow onOpen={() => undefined} repoPath="/Users/someone/www/repo" />)
|
||||
|
||||
// Painted tildified, copied raw.
|
||||
expect(screen.getByText('~/www/repo')).toBeTruthy()
|
||||
|
||||
const copy = screen.getByRole('button', { name: 'Copy path' })
|
||||
|
||||
fireEvent.click(copy)
|
||||
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('/Users/someone/www/repo'))
|
||||
// Confirmation is the button turning into a checkmark, not a notification.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy())
|
||||
expect($notifications.get()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { memo, useEffect } from 'react'
|
||||
|
||||
import { PrTag } from '@/app/chat/pr-tag'
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import {
|
||||
type ActionItemSpec,
|
||||
ActionsContextMenu,
|
||||
ActionsMenu,
|
||||
type MenuKit,
|
||||
renderActionItem
|
||||
} from '@/components/ui/actions-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { DiffCount } from '@/components/ui/diff-count'
|
||||
import type { HermesGitBranch } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { displayPath } from '@/lib/display-path'
|
||||
import { openWorktreeDialog, registerRepoStatusCwd, repoStatusForCwd, repoWorktreesForCwd } from '@/store/coding-status'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $pullRequestsByBranch, branchPrKey, refreshPullRequests } from '@/store/pull-requests'
|
||||
|
||||
// Tiny uppercase section header, matching the composer "+" menu's labels.
|
||||
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
|
||||
|
||||
interface CodingStatusRowProps {
|
||||
/** Branch the current draft off into a fresh worktree + session, based on
|
||||
* `base` (a branch name; omitted = current HEAD). The composer owns the
|
||||
* draft, so it supplies the orchestration; the row just collects the new
|
||||
* branch name + base. Omitted (e.g. remote backend) hides the affordance. */
|
||||
onBranchOff?: (branch: string, base?: string) => Promise<void>
|
||||
/** Check an existing branch out into a fresh worktree + session (no new
|
||||
* branch). Drives the dialog's "convert a branch" picker. */
|
||||
onConvertBranch?: (branch: string, path?: null | string, isDefault?: boolean) => Promise<void>
|
||||
/** List the repo's local branches for the "convert a branch" picker. */
|
||||
onListBranches?: () => Promise<HermesGitBranch[]>
|
||||
/** Open the review pane (changed files + diffs). */
|
||||
onOpen?: () => void
|
||||
/** Jump into an existing worktree (open a fresh session anchored there). */
|
||||
onOpenWorktree?: (path: string) => void
|
||||
/** Switch the current repo checkout to another branch. */
|
||||
onSwitchBranch?: (branch: string) => Promise<void>
|
||||
/** Repo root path for the worktree dialog. */
|
||||
repoPath?: null | string
|
||||
}
|
||||
|
||||
/**
|
||||
* The always-on coding-context row, the BASE of the composer status stack:
|
||||
* current branch, dirty summary (+/-), and ahead/behind. A touch more prominent
|
||||
* than the per-turn rows above it (larger branch label, accent glyph), and the
|
||||
* entry point to the review pane. Hidden when the active session isn't in a
|
||||
* local git repo (the probe returns null).
|
||||
*/
|
||||
export const CodingStatusRow = memo(function CodingStatusRow({
|
||||
onBranchOff,
|
||||
onConvertBranch,
|
||||
onListBranches,
|
||||
onOpen,
|
||||
onOpenWorktree,
|
||||
onSwitchBranch,
|
||||
repoPath
|
||||
}: CodingStatusRowProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack.coding
|
||||
const p = t.sidebar.projects
|
||||
const fileMenu = t.fileMenu
|
||||
const resolvedRepoPath = repoPath?.trim() || undefined
|
||||
// This surface's OWN worktree, always — never the primary's. The row used to
|
||||
// fall back to the global `$repoStatus` for a blank repoPath, which painted
|
||||
// the main pane's branch/± onto a tile whose cwd hadn't resolved yet. That
|
||||
// fallback bought nothing (the primary's computed is keyed to `$currentCwd`,
|
||||
// which is blank in exactly the same case) and cost a wrong-tree rail.
|
||||
const status = useStore(repoStatusForCwd(resolvedRepoPath))
|
||||
const worktrees = useStore(repoWorktreesForCwd(resolvedRepoPath))
|
||||
|
||||
// While mounted, keep this worktree in the coding-status refresh set so the
|
||||
// turn-settle / tool-complete / focus edges re-probe it too (tiles otherwise
|
||||
// only refreshed when the MAIN cwd probe happened to cover them).
|
||||
useEffect(() => registerRepoStatusCwd(resolvedRepoPath), [resolvedRepoPath])
|
||||
|
||||
// The branch's PR, so the rail links to it instead of leaving you to go find
|
||||
// it. One `gh` lookup for this one branch, TTL-cached in the store and shared
|
||||
// with the sidebar's badges.
|
||||
const prBranch = status?.detached ? null : status?.branch || null
|
||||
|
||||
useEffect(() => {
|
||||
if (resolvedRepoPath && prBranch) {
|
||||
void refreshPullRequests({ [resolvedRepoPath]: [prBranch] })
|
||||
}
|
||||
}, [resolvedRepoPath, prBranch])
|
||||
|
||||
const pr =
|
||||
useStore($pullRequestsByBranch)[resolvedRepoPath && prBranch ? branchPrKey(resolvedRepoPath, prBranch) : '']
|
||||
|
||||
const switchToBranch = async (branch: string) => {
|
||||
if (!onSwitchBranch) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await onSwitchBranch(branch)
|
||||
} catch (err) {
|
||||
notifyError(err, s.switchFailed(branch))
|
||||
}
|
||||
}
|
||||
|
||||
// useKeybinds now handles the ⌘⇧B hotkey globally, through
|
||||
// openWorktreeDialog. One dialog is mounted in the sidebar, so N mounted
|
||||
// rails can no longer each open their own copy. The menu items below only
|
||||
// publish the intent. They pin the repo of THIS rail, so the kebab of a tile
|
||||
// targets the worktree of that tile.
|
||||
const startBranch = (base: string | undefined) => {
|
||||
void openWorktreeDialog({ base, repoPath: resolvedRepoPath })
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
|
||||
const branchLabel = status.detached ? s.detached : status.branch || s.noBranch
|
||||
// The kebab offers branching off the trunk and/or the current branch. The
|
||||
// worktree-add bases the new branch on `base` (a branch name; undefined =
|
||||
// current HEAD). We dedupe so "on main" shows a single trunk entry, and fall
|
||||
// back to a plain off-HEAD branch when no trunk is detected.
|
||||
const current = status.detached ? null : status.branch
|
||||
const branchTargets: { base: string | undefined; label: string }[] = []
|
||||
|
||||
// Current branch first (the 99% "branch off where I am"), then the trunk just
|
||||
// below it ("New branch from main"), deduped when they're the same.
|
||||
if (current) {
|
||||
branchTargets.push({ base: current, label: s.branchOffFrom(current) })
|
||||
}
|
||||
|
||||
if (status.defaultBranch && status.defaultBranch !== current) {
|
||||
branchTargets.push({ base: status.defaultBranch, label: s.branchOffFrom(status.defaultBranch) })
|
||||
}
|
||||
|
||||
if (branchTargets.length === 0) {
|
||||
branchTargets.push({ base: undefined, label: s.newBranch })
|
||||
}
|
||||
|
||||
const switchTarget =
|
||||
onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
|
||||
|
||||
// Other worktrees to jump into — everything except the one we're already in
|
||||
// (matched by its checked-out branch) and the bare/main placeholder entry.
|
||||
const otherWorktrees = onOpenWorktree
|
||||
? worktrees.filter(w => w.path && !w.detached && w.branch && w.branch !== current)
|
||||
: []
|
||||
|
||||
const hasLineDelta = status.added > 0 || status.removed > 0
|
||||
// Untracked files carry no line delta vs HEAD, so surface them as a count when
|
||||
// they're the only change (otherwise +/- tells the story).
|
||||
const untrackedOnly = !hasLineDelta && status.untracked > 0
|
||||
|
||||
// The branch actions, rendered identically by the kebab dropdown and the
|
||||
// row's right-click menu so the two never drift. `onBranchOff` gates the
|
||||
// whole menu (omitted = remote backend), matching the kebab.
|
||||
const renderBranchItems = (kit: MenuKit) => {
|
||||
const branchItems: ActionItemSpec[] = branchTargets.map(target => ({
|
||||
key: target.base ?? '__head__',
|
||||
label: <span className="truncate">{target.label}</span>,
|
||||
onSelect: () => startBranch(target.base)
|
||||
}))
|
||||
|
||||
const worktreeItems: ActionItemSpec[] = otherWorktrees.map(worktree => ({
|
||||
key: worktree.path,
|
||||
label: <span className="truncate">{worktree.branch}</span>,
|
||||
onSelect: () => onOpenWorktree?.(worktree.path)
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<kit.Label className={MENU_SECTION}>{s.newBranch}</kit.Label>
|
||||
{branchItems.map(item => renderActionItem(kit, item))}
|
||||
{switchTarget &&
|
||||
renderActionItem(kit, {
|
||||
key: '__switch__',
|
||||
label: <span className="truncate">{s.switchTo(switchTarget)}</span>,
|
||||
onSelect: () => void switchToBranch(switchTarget)
|
||||
})}
|
||||
<kit.Separator />
|
||||
<kit.Label className={MENU_SECTION}>{s.worktrees}</kit.Label>
|
||||
{worktreeItems.map(item => renderActionItem(kit, item))}
|
||||
{/* Create a fresh worktree off the current HEAD (the generic "spin up a
|
||||
worktree here", mirroring the sidebar's + button). */}
|
||||
{renderActionItem(kit, {
|
||||
key: '__start__',
|
||||
label: <span className="truncate">{p.startWork}</span>,
|
||||
onSelect: () => startBranch(undefined)
|
||||
})}
|
||||
{onConvertBranch &&
|
||||
renderActionItem(kit, {
|
||||
key: '__convert__',
|
||||
label: <span className="truncate">{p.convertBranch}</span>,
|
||||
onSelect: () => startBranch(undefined)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionsContextMenu contentClassName="w-60" disabled={!onBranchOff} items={renderBranchItems}>
|
||||
<StatusRow
|
||||
// The base "where am I working" strip is part of the composer surface
|
||||
// itself, so it inherits the composer's width and clipped top radius.
|
||||
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
|
||||
// Static branch glyph — never the loading spinner. This row only renders
|
||||
// once `status` exists, so a spinner here only ever fired on *refreshes*
|
||||
// of an already-loaded repo (window focus, turn settle), reading as an
|
||||
// annoying icon "blip" with no first-load value. Refreshes are silent.
|
||||
// It's a button (not the whole row) so the glyph opens the review pane
|
||||
// while the strip around it stays inert; size-3.5 fills the slot exactly.
|
||||
leading={
|
||||
<button className="flex size-3.5 items-center justify-center" onClick={onOpen} type="button">
|
||||
<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
{/* PR number first, right against the leading git glyph — the chip
|
||||
borrows that icon instead of carrying a second one of its own
|
||||
(`showIcon={false}`), so the row reads glyph → #number → branch. */}
|
||||
{pr && <PrTag pr={pr} showIcon={false} />}
|
||||
|
||||
{/* Branch name — the other half of the review-pane target. `contents`
|
||||
so the button lays out nothing of its own: the label stays the
|
||||
same flex child it always was, and the hit area is the text. */}
|
||||
<button className="contents" onClick={onOpen} type="button">
|
||||
<span className="min-w-0 truncate text-xs font-normal text-muted-foreground/92" title={branchLabel}>
|
||||
{branchLabel}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Worktree path + copy — plain muted text, not a chip. Always in the
|
||||
flex so hover doesn't reflow the row; opacity alone reveals the
|
||||
pair. The path sizes to its content (the `flex-1` lives on the
|
||||
wrapper) so the glyph sits against the end of the text instead of
|
||||
drifting to the far edge of the row. `displayPath` collapses
|
||||
home → ~; the copy still takes the real absolute path, and it's
|
||||
the shared `CopyButton` so it confirms with the same inline
|
||||
checkmark as every other copy in the app. */}
|
||||
{resolvedRepoPath && (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-0.5 opacity-0 transition-opacity group-hover/status-row:opacity-100 group-focus-within/status-row:opacity-100">
|
||||
<span
|
||||
className="min-w-0 truncate font-mono text-[0.62rem] leading-4 text-muted-foreground/50"
|
||||
data-slot="coding-status-cwd"
|
||||
>
|
||||
{displayPath(resolvedRepoPath)}
|
||||
</span>
|
||||
<CopyButton
|
||||
appearance="icon"
|
||||
buttonSize="icon-xs"
|
||||
className="pointer-events-none size-4 shrink-0 text-muted-foreground/50 hover:text-foreground group-hover/status-row:pointer-events-auto group-focus-within/status-row:pointer-events-auto"
|
||||
iconClassName="size-3"
|
||||
label={fileMenu.copyPath}
|
||||
side="top"
|
||||
stopPropagation
|
||||
text={resolvedRepoPath}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch actions kebab — same pattern as the session/worktree rows.
|
||||
ALWAYS laid out; only its opacity flips on hover/focus/open, so
|
||||
revealing it never reflows the row (no layout shift). pointer-events
|
||||
follow opacity so the invisible trigger isn't clickable at rest. */}
|
||||
{onBranchOff && (
|
||||
<ActionsMenu
|
||||
align="end"
|
||||
contentClassName="w-60"
|
||||
// The row sits at the bottom of the screen (above the composer),
|
||||
// so the menu opens upward.
|
||||
items={renderBranchItems}
|
||||
side="top"
|
||||
>
|
||||
<Button
|
||||
aria-label={s.newBranch}
|
||||
className="pointer-events-none size-4 shrink-0 text-muted-foreground/60 opacity-0 transition hover:text-foreground group-hover/status-row:pointer-events-auto group-hover/status-row:opacity-100 group-focus-within/status-row:pointer-events-auto group-focus-within/status-row:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:opacity-100"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.8rem" />
|
||||
</Button>
|
||||
</ActionsMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The counts describe what's in the review pane, so clicking them
|
||||
opens it. `contents` again: the two spans stay direct flex children
|
||||
of the row, keeping their gap and `ml-auto` behaviour untouched. */}
|
||||
{(status.ahead > 0 || status.behind > 0 || hasLineDelta || untrackedOnly) && (
|
||||
<button className="contents" onClick={onOpen} type="button">
|
||||
{(status.ahead > 0 || status.behind > 0) && (
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
|
||||
{status.ahead > 0 && (
|
||||
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
|
||||
<span aria-hidden>↑</span>
|
||||
{status.ahead}
|
||||
</span>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
|
||||
<span aria-hidden>↓</span>
|
||||
{status.behind}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasLineDelta ? (
|
||||
<DiffCount
|
||||
added={status.added}
|
||||
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
|
||||
removed={status.removed}
|
||||
/>
|
||||
) : untrackedOnly ? (
|
||||
<span
|
||||
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
|
||||
>
|
||||
{s.changed(status.untracked)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
</StatusRow>
|
||||
</ActionsContextMenu>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import { $threadScrolledUp, resetThreadScroll } from '@/store/thread-scroll'
|
||||
import { resetThreadScroll, setThreadAtBottom } from '@/store/thread-scroll'
|
||||
|
||||
import { ComposerStatusStack } from './index'
|
||||
|
||||
@@ -17,19 +17,20 @@ vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
|
||||
describe('ComposerStatusStack scroll treatment', () => {
|
||||
beforeEach(() => {
|
||||
$threadScrolledUp.set(true)
|
||||
setThreadAtBottom(false, 'sess-a')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
resetThreadScroll()
|
||||
resetThreadScroll('sess-a')
|
||||
})
|
||||
|
||||
it('dims only the status content while keeping the dock card opaque', () => {
|
||||
const view = render(
|
||||
<MemoryRouter>
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<ComposerStatusStack queue={<div>Queued task</div>} sessionId={null} />
|
||||
<ComposerStatusStack queue={<div>Queued task</div>} sessionId="sess-a" />
|
||||
<ComposerStatusStack queue={<div>Sibling task</div>} sessionId="sess-b" />
|
||||
</I18nProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
@@ -42,5 +43,6 @@ describe('ComposerStatusStack scroll treatment', () => {
|
||||
expect(dimmedContent).not.toBeNull()
|
||||
expect(dimmedContent).not.toBe(card)
|
||||
expect(card?.contains(dimmedContent)).toBe(true)
|
||||
expect(screen.getByText('Sibling task').closest('.opacity-30')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ReactNode, useEffect, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { blurComposerInput } from '@/app/chat/composer/focus'
|
||||
import { useComposerSurfaceId } from '@/app/chat/composer/scope'
|
||||
import { AGENTS_ROUTE } from '@/app/routes'
|
||||
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { BillingBanner } from '@/components/billing-banner'
|
||||
@@ -15,7 +16,7 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { useSessionSlice } from '@/lib/use-session-slice'
|
||||
import { useSessionSlice, useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $billingBlock } from '@/store/billing-block'
|
||||
import {
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
import { $freeTierRoute, $freeTierStatus, freeTierStripPending } from '@/store/free-tier'
|
||||
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
|
||||
import { $sessionControlBySession, refreshSessionControl } from '@/store/session-control'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { $threadScrolledUpBySession } from '@/store/thread-scroll'
|
||||
import { openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { PreviewStatusRow } from './preview-row'
|
||||
@@ -107,7 +108,13 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat
|
||||
const previews = useSessionSlice($previewStatusBySession, sessionId)
|
||||
const controlEntry = useSessionValue($sessionControlBySession, sessionId)
|
||||
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const surfaceId = useComposerSurfaceId()
|
||||
const scrollSessionId = sessionId ?? surfaceId
|
||||
|
||||
const scrolledUp = useStoreSelector($threadScrolledUpBySession, map =>
|
||||
Boolean(scrollSessionId && map[scrollSessionId])
|
||||
)
|
||||
|
||||
const billing = useStore($billingBlock)
|
||||
const freeTierStatus = useStore($freeTierStatus)
|
||||
const freeTierRoute = useStore($freeTierRoute)
|
||||
|
||||
@@ -26,6 +26,11 @@ export interface ChatBarState {
|
||||
quickModels?: QuickModelOption[]
|
||||
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
|
||||
modelMenuContent?: ReactNode
|
||||
/** The reasoning pill's dropdown (same host + controller as the model menu). */
|
||||
reasoningMenuContent?: ReactNode
|
||||
/** False when the catalog says the active model has no reasoning control;
|
||||
* undefined while unknown (loading) so the pill stays put. */
|
||||
supportsReasoning?: boolean
|
||||
}
|
||||
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
|
||||
voice: { enabled: boolean; active: boolean }
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs'
|
||||
import { composerPlainText, refChipElement, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import {
|
||||
chipTypedUrlOnSpace,
|
||||
linkifyUrls,
|
||||
markdownLinkFor,
|
||||
resolveExactLinkPaste,
|
||||
selectionLinkLabel
|
||||
} from './url-refs'
|
||||
|
||||
/** An editor holding `text` with a collapsed caret at `caret`, plus the space
|
||||
* keydown the composer would hand `chipTypedUrlOnSpace`. */
|
||||
@@ -51,6 +57,95 @@ describe('linkifyUrls', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveExactLinkPaste', () => {
|
||||
it('accepts a lone bare link', () => {
|
||||
expect(resolveExactLinkPaste('https://example.dev/a/b')).toBe('https://example.dev/a/b')
|
||||
})
|
||||
|
||||
it('accepts a wrapped <link> and surrounding whitespace', () => {
|
||||
expect(resolveExactLinkPaste(' <https://example.dev/a> ')).toBe('https://example.dev/a')
|
||||
})
|
||||
|
||||
it('rejects prose around the link', () => {
|
||||
expect(resolveExactLinkPaste('see https://example.dev')).toBeNull()
|
||||
expect(resolveExactLinkPaste('https://example.dev is nice')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects multiple links', () => {
|
||||
expect(resolveExactLinkPaste('https://a.dev https://b.dev')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects trailing sentence punctuation and hostless schemes', () => {
|
||||
expect(resolveExactLinkPaste('https://example.dev.')).toBeNull()
|
||||
expect(resolveExactLinkPaste('https://')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectionLinkLabel', () => {
|
||||
const selectAll = (build: (editor: HTMLElement) => void) => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
build(editor)
|
||||
document.body.append(editor)
|
||||
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.selectNodeContents(editor)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
it('returns the selected text', () => {
|
||||
const editor = selectAll(node => {
|
||||
node.textContent = 'the docs'
|
||||
})
|
||||
|
||||
expect(selectionLinkLabel(editor)).toBe('the docs')
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('rejects a collapsed selection', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.textContent = 'text'
|
||||
document.body.append(editor)
|
||||
window.getSelection()?.removeAllRanges()
|
||||
|
||||
expect(selectionLinkLabel(editor)).toBeNull()
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('rejects a selection containing a chip', () => {
|
||||
const editor = selectAll(node => {
|
||||
node.append(document.createTextNode('see '), refChipElement('url', '`https://a.dev`'))
|
||||
})
|
||||
|
||||
expect(selectionLinkLabel(editor)).toBeNull()
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('rejects a multi-line selection', () => {
|
||||
const editor = selectAll(node => {
|
||||
node.append(document.createTextNode('one'), document.createElement('br'), document.createTextNode('two'))
|
||||
})
|
||||
|
||||
expect(selectionLinkLabel(editor)).toBeNull()
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('markdownLinkFor', () => {
|
||||
it('builds a markdown link', () => {
|
||||
expect(markdownLinkFor('the docs', 'https://example.dev')).toBe('[the docs](https://example.dev)')
|
||||
})
|
||||
|
||||
it('escapes square brackets in the label', () => {
|
||||
expect(markdownLinkFor('a [b] c', 'https://example.dev')).toBe('[a \\[b\\] c](https://example.dev)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('chipTypedUrlOnSpace', () => {
|
||||
it('chips a link typed right before the caret and adds the space', () => {
|
||||
const { editor, event } = spaceOn('see https://example.dev/a', 25)
|
||||
|
||||
@@ -59,6 +59,71 @@ export function linkifyUrls(text: string) {
|
||||
return out + text.slice(cursor)
|
||||
}
|
||||
|
||||
/** The href to apply when a clipboard payload is exactly ONE supported link —
|
||||
* a bare or `<…>`-wrapped `http(s)` URL with a host and nothing else. Null for
|
||||
* anything that isn't a lone link (prose, multiple links, trailing text), so
|
||||
* callers fall through to the normal paste pipeline. Ported from
|
||||
* block/buzz#6684's `resolveExactLinkPaste`. */
|
||||
export function resolveExactLinkPaste(raw: string): string | null {
|
||||
const text = raw.trim()
|
||||
const unwrapped = text.startsWith('<') && text.endsWith('>') && text.length > 2 ? text.slice(1, -1).trim() : text
|
||||
|
||||
URL_RE.lastIndex = 0
|
||||
|
||||
const match = URL_RE.exec(unwrapped)
|
||||
|
||||
if (!match || match.index !== 0 || match[0].length !== unwrapped.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { trailing, url } = splitUrlTail(unwrapped)
|
||||
|
||||
// Trailing sentence punctuation means the user copied prose, not a link.
|
||||
if (trailing || !hasHost(url)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/** The selected composer text a link paste should hyperlink, or null when the
|
||||
* selection can't take a link mark: collapsed, outside `editor`, spanning
|
||||
* chips or line breaks, or whitespace-only. */
|
||||
export function selectionLinkLabel(editor: HTMLElement): string | null {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
if (!editor.contains(range.commonAncestorContainer)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const probe = document.createElement('div')
|
||||
|
||||
probe.append(range.cloneContents())
|
||||
|
||||
// A chip inside the selection is a directive, not prose — linking over it
|
||||
// would destroy the reference. Multi-line selections don't read as a label.
|
||||
if (probe.querySelector('[data-ref-text], br')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = probe.textContent?.replace(/\s+/g, ' ').trim() ?? ''
|
||||
|
||||
return label || null
|
||||
}
|
||||
|
||||
/** Markdown link for a paste-over-selection: the label the user selected, the
|
||||
* URL they pasted. Square brackets in the label are escaped so the link
|
||||
* survives markdown parsing downstream. */
|
||||
export function markdownLinkFor(label: string, url: string): string {
|
||||
return `[${label.replace(/([[\]])/g, '\\$1')}](${url})`
|
||||
}
|
||||
|
||||
/** A plain space finishing a typed link commits it as a chip (followed by
|
||||
* whatever punctuation ended it, then the space). Returns whether it ran, so a
|
||||
* keydown handler can fall through on anything else. */
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { FanMenu, type FanMenuItem } from '@/components/ui/fan-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Ear, EarOff, iconSize, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
|
||||
|
||||
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN } from './control-classes'
|
||||
import type { ChatBarState, VoiceStatus } from './types'
|
||||
|
||||
export interface VoiceFanProps {
|
||||
autoSpeak: boolean
|
||||
disabled: boolean
|
||||
state: ChatBarState
|
||||
voiceStatus: VoiceStatus
|
||||
onDictate: () => void
|
||||
onToggleAutoSpeak: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The voice toggles behind one hub. The mic is the button in the row; hovering
|
||||
* it fans the other two — spoken replies and the wake word — out of it.
|
||||
* Starting a conversation stays on the primary button beside it.
|
||||
*
|
||||
* The hub is the mic and reports dictation only (recording, transcribing);
|
||||
* each disc carries its own on-state.
|
||||
*
|
||||
* Items are memoized on the handful of state bits they read, so the fan only
|
||||
* re-renders when a toggle actually flips — not on every composer keystroke.
|
||||
*/
|
||||
export function VoiceFan({ autoSpeak, disabled, state, voiceStatus, onDictate, onToggleAutoSpeak }: VoiceFanProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const wake = useStore($wakeWord)
|
||||
|
||||
const phrase = wake.phrase || 'hey hermes'
|
||||
const dictating = state.voice.active || voiceStatus !== 'idle'
|
||||
const wakeListening = wake.listening
|
||||
const wakePending = wake.pending
|
||||
|
||||
const dictationLabel =
|
||||
voiceStatus === 'recording'
|
||||
? c.stopDictation
|
||||
: voiceStatus === 'transcribing'
|
||||
? c.transcribingDictation
|
||||
: c.voiceDictation
|
||||
|
||||
const hubLabel = dictating ? dictationLabel : c.voiceDictation
|
||||
|
||||
const dictate = useCallback(() => {
|
||||
triggerHaptic(dictating ? 'close' : 'open')
|
||||
onDictate()
|
||||
}, [dictating, onDictate])
|
||||
|
||||
// The hub is the mic and only dictation lights it. The wake word has its own
|
||||
// disc with its own on-state; mirroring it here read as dictation being on.
|
||||
const hub = useMemo(
|
||||
() => ({
|
||||
id: 'dictate',
|
||||
active: dictating,
|
||||
className: cn(GHOST_ICON_BTN, 'rounded-full p-0', dictating && ACTIVE_ICON_BTN),
|
||||
disabled: disabled || !state.voice.enabled || voiceStatus === 'transcribing',
|
||||
icon:
|
||||
voiceStatus === 'recording' ? (
|
||||
<Square className={cn('fill-current', iconSize.xs)} />
|
||||
) : voiceStatus === 'transcribing' ? (
|
||||
<Loader2 className={cn('animate-spin', iconSize.sm)} />
|
||||
) : (
|
||||
<Codicon name="mic" size="0.875rem" />
|
||||
),
|
||||
label: hubLabel,
|
||||
onSelect: dictate
|
||||
}),
|
||||
[dictate, dictating, disabled, hubLabel, state.voice.enabled, voiceStatus]
|
||||
)
|
||||
|
||||
const items = useMemo<FanMenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'speak',
|
||||
active: autoSpeak,
|
||||
disabled,
|
||||
icon: autoSpeak ? <Volume2 className={iconSize.sm} /> : <VolumeX className={iconSize.sm} />,
|
||||
label: autoSpeak ? c.stopSpeakingReplies : c.speakReplies,
|
||||
onSelect: () => {
|
||||
triggerHaptic(autoSpeak ? 'close' : 'open')
|
||||
onToggleAutoSpeak()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'wake',
|
||||
active: wakeListening,
|
||||
disabled: disabled || wakePending,
|
||||
icon: wakeListening ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />,
|
||||
label: c.wakeWord(phrase),
|
||||
onSelect: () => {
|
||||
triggerHaptic(wakeListening ? 'close' : 'open')
|
||||
void toggleWakeWord()
|
||||
}
|
||||
}
|
||||
],
|
||||
[autoSpeak, c, disabled, onToggleAutoSpeak, phrase, wakeListening, wakePending]
|
||||
)
|
||||
|
||||
return <FanMenu direction="vertical" hub={hub} items={items} label={c.voiceControls} />
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export function VoiceMenu({
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tip label={wake.notice && !dictating ? `${triggerLabel} — ${wake.notice}` : triggerLabel}>
|
||||
<Tip label={wake.notice && !dictating ? `${triggerLabel} — ${wake.notice}` : triggerLabel} side="left">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={triggerLabel}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { $composerAttachments, type ComposerAttachment, updateComposerAttachment } from '@/store/composer'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { droppedFileInlineRefs } from '../composer/inline-refs'
|
||||
|
||||
import {
|
||||
attachmentPreviewDataUrl,
|
||||
type DroppedFile,
|
||||
@@ -87,9 +89,14 @@ interface StubEntry {
|
||||
isDirectory: boolean
|
||||
}
|
||||
|
||||
function stubTransfer(entries: StubEntry[], internalRaw = ''): DataTransfer & { _pathByFile: Map<File, string> } {
|
||||
function stubTransfer(
|
||||
entries: StubEntry[],
|
||||
internalRaw = '',
|
||||
uriList = ''
|
||||
): DataTransfer & { _pathByFile: Map<File, string> } {
|
||||
const files = entries.map(entry => new File(['x'], entry.path.split('/').pop() || 'f'))
|
||||
const pathByFile = new Map(files.map((file, i) => [file, entries[i].path]))
|
||||
// A virtual shortcut File (browser link drag on Windows) has a name but no path.
|
||||
const pathByFile = new Map(files.map((file, i) => [file, entries[i].path.includes('/') ? entries[i].path : '']))
|
||||
|
||||
const items: Record<number | string, unknown> = { length: entries.length }
|
||||
entries.forEach((entry, i) => {
|
||||
@@ -101,7 +108,7 @@ function stubTransfer(entries: StubEntry[], internalRaw = ''): DataTransfer & {
|
||||
})
|
||||
|
||||
return {
|
||||
getData: (mime: string) => (mime === HERMES_PATHS_MIME ? internalRaw : ''),
|
||||
getData: (mime: string) => (mime === HERMES_PATHS_MIME ? internalRaw : mime === 'text/uri-list' ? uriList : ''),
|
||||
files: {
|
||||
length: files.length,
|
||||
item: (i: number) => files[i] ?? null
|
||||
@@ -174,6 +181,41 @@ describe('extractDroppedFiles', () => {
|
||||
expect(osDrops.map(entry => entry.path)).toEqual(['/abs/notes.txt'])
|
||||
})
|
||||
|
||||
it('turns a browser link drag into an @url chip instead of failing on the virtual .url stub', () => {
|
||||
// Dragging a link out of a browser on Windows lands as `text/uri-list` plus a
|
||||
// path-less `<title>.url` shortcut File. That stub used to reach the upload
|
||||
// pipeline and toast "Could not attach agent-wiki.url".
|
||||
const transfer = stubTransfer(
|
||||
[{ path: 'agent-wiki.url', isDirectory: false }],
|
||||
'',
|
||||
'https://example.com/wiki/agent?x=1\r\n'
|
||||
) as DataTransfer & { _pathByFile: Map<File, string> }
|
||||
|
||||
stubBridge(transfer)
|
||||
|
||||
const result = extractDroppedFiles(transfer)
|
||||
|
||||
expect(result).toEqual([{ path: '', url: 'https://example.com/wiki/agent?x=1' }])
|
||||
expect(partitionDroppedFiles(result).osDrops).toEqual([])
|
||||
expect(droppedFileInlineRefs(result, '/w')).toEqual(['@url:https://example.com/wiki/agent?x=1'])
|
||||
})
|
||||
|
||||
it('keeps a path-less image dragged off a web page as an upload, not a link chip', () => {
|
||||
const transfer = stubTransfer(
|
||||
[{ path: 'logo.png', isDirectory: false }],
|
||||
'',
|
||||
'https://example.com/logo.png'
|
||||
) as DataTransfer & { _pathByFile: Map<File, string> }
|
||||
|
||||
stubBridge(transfer)
|
||||
|
||||
const result = extractDroppedFiles(transfer)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.file).toBeInstanceOf(File)
|
||||
expect(result[0]?.url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not duplicate a folder that appears in both items and files', () => {
|
||||
// Chromium lists a dropped folder in transfer.files too (as a size-0 File);
|
||||
// the items pass claims its path first so the files fallback skips it.
|
||||
|
||||
@@ -90,6 +90,8 @@ export interface DroppedFile {
|
||||
line?: number
|
||||
/** Last line number for line-range drags (`line..lineEnd` inclusive). */
|
||||
lineEnd?: number
|
||||
/** A link dragged out of a browser (`text/uri-list`). Path-less; becomes an `@url:` chip. */
|
||||
url?: string
|
||||
}
|
||||
|
||||
/** MIME emitted by in-app drag sources (project tree, gutter line numbers).
|
||||
@@ -110,6 +112,7 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
||||
const seenPaths = new Set<string>()
|
||||
const seenFiles = new Set<File>()
|
||||
const getPath = window.hermesDesktop?.getPathForFile
|
||||
const urls = droppedLinkUrls(transfer)
|
||||
|
||||
// In-app drags first — they carry richer metadata (isDirectory) than the
|
||||
// File-based fallback can provide, and produce no overlapping native files.
|
||||
@@ -169,6 +172,20 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
||||
}
|
||||
}
|
||||
|
||||
// A link dragged out of a browser rides along as a virtual shortcut File
|
||||
// (`<title>.url` on Windows, `.webloc` on macOS) with no on-disk path. It
|
||||
// is the same link `text/uri-list` already carries, so drop the stub and
|
||||
// let the URL become a chip instead of toasting "Could not attach X.url".
|
||||
// A path-less *image* (dragged off a web page) keeps its bytes and wins
|
||||
// over the link to its own src.
|
||||
if (!path && urls.length) {
|
||||
if (isImagePath(file.name) || file.type.startsWith('image/')) {
|
||||
urls.length = 0
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (path && seenPaths.has(path)) {
|
||||
return
|
||||
}
|
||||
@@ -238,9 +255,37 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of urls) {
|
||||
result.push({ path: '', url })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** `http(s)` links from a `text/uri-list` payload (one per line, `#` comments
|
||||
* skipped), deduped. Empty when the drag carried none. */
|
||||
function droppedLinkUrls(transfer: DataTransfer): string[] {
|
||||
let raw = ''
|
||||
|
||||
try {
|
||||
raw = transfer.getData('text/uri-list') || ''
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const urls: string[] = []
|
||||
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const url = line.trim()
|
||||
|
||||
if (/^https?:\/\/[^/\s]/i.test(url) && !urls.includes(url)) {
|
||||
urls.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
/**
|
||||
* Split dropped entries by origin. OS/Finder drops carry a native `File`
|
||||
* handle; in-app drags (project tree, gutter line refs) are path-only.
|
||||
|
||||
@@ -43,6 +43,7 @@ vi.mock('@/components/Backdrop', async () => {
|
||||
vi.mock('@/components/prompt-overlays', () => ({ PromptOverlays: () => null }))
|
||||
vi.mock('@/components/chat/vibe-hearts', () => ({ COMPOSER_HEART_CONFIG: {}, HeartField: () => null }))
|
||||
vi.mock('@/lib/model-options', () => ({
|
||||
currentModelCapabilities: () => undefined,
|
||||
modelOptionsQueryKey: (...parts: unknown[]) => ['model-options', ...parts],
|
||||
requestModelOptions: vi.fn(async () => ({ models: [] }))
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react'
|
||||
import type { ModelOptionsResult } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
@@ -23,7 +24,7 @@ import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { NEW_SESSION_TITLE, quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
|
||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
|
||||
import { currentModelCapabilities, modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { migrateSessionDraft } from '@/store/composer'
|
||||
@@ -48,10 +49,9 @@ import {
|
||||
sessionPinId,
|
||||
shouldMigrateComposerScope
|
||||
} from '@/store/session'
|
||||
import { $focusedStoredSessionId, sessionTileDelegate } from '@/store/session-states'
|
||||
import { $focusedStoredSessionId, $sessionStates, sessionTileDelegate } from '@/store/session-states'
|
||||
import { $transcriptTailBySessionId, transcriptTailState } from '@/store/transcript-tail'
|
||||
import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { primaryRouteSelectedSessionId, routeSessionId } from '../routes'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar'
|
||||
@@ -86,6 +86,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
modelOptionsOwnerConnectionId?: string
|
||||
modelOptionsProfile?: string
|
||||
modelMenuContent?: React.ReactNode
|
||||
reasoningMenuContent?: React.ReactNode
|
||||
requestModelOptionsForOwner?: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
onToggleSelectedPin: () => void
|
||||
onDeleteSelectedSession: () => void
|
||||
@@ -235,7 +236,7 @@ function useMessagesWhileVisible($messages: ReadableAtom<ChatMessage[]>): ChatMe
|
||||
* of re-rendering them by element identity and the stream's render cost stays
|
||||
* confined to the streaming message's own subtree.
|
||||
*/
|
||||
function ChatRuntimeBoundary({
|
||||
export function ChatRuntimeBoundary({
|
||||
busy,
|
||||
children,
|
||||
onCancel,
|
||||
@@ -307,38 +308,54 @@ function ChatRuntimeBoundary({
|
||||
const tailState = storedId && transcriptTailStates ? transcriptTailState(storedId, tailProfile) : undefined
|
||||
const restBackfillAvailable = Boolean(tailState?.possiblyTruncated)
|
||||
|
||||
const expandWindow = useCallback(() => {
|
||||
// The store window still holds older messages: growing pages is enough.
|
||||
// Otherwise the whole in-memory transcript is already materialized — if
|
||||
// the REST tail hydration was truncated, fetch the next older page and
|
||||
// PREPEND it to the session store before growing, so the grown window has
|
||||
// something older to show. Fire-and-forget: the prepend lands through the
|
||||
// session-state write path and re-renders this boundary.
|
||||
if (
|
||||
!windowStateRef.current.get(runtimeIdRef.current ?? '')?.state.window.windowed &&
|
||||
runtimeId &&
|
||||
storedId &&
|
||||
transcriptBackfillAvailable(storedId, tailProfile)
|
||||
) {
|
||||
void backfillOlderTranscriptPage({
|
||||
storedSessionId: storedId,
|
||||
profile: tailProfile,
|
||||
// Stale-response guard: a session switch remounts/re-keys this view;
|
||||
// checking the live atoms (not captured props) discards a page that
|
||||
// resolves after the user moved on — same pattern as isCurrentResume.
|
||||
isCurrent: () => view.$storedId.get() === storedId && view.$runtimeId.get() === runtimeId,
|
||||
applyOlderPage: olderPage => {
|
||||
sessionTileDelegate()?.updateSession(runtimeId, state => {
|
||||
const merged = mergeOlderTranscriptPage(state.messages, olderPage)
|
||||
const expandWindow = useCallback(
|
||||
async (beforePrepend?: () => void) => {
|
||||
// Network latency is not scroll intent. Capture at arrival, immediately
|
||||
// before the store prepend, and only grow a window that has a page to show.
|
||||
if (
|
||||
!windowStateRef.current.get(runtimeIdRef.current ?? '')?.state.window.windowed &&
|
||||
runtimeId &&
|
||||
storedId &&
|
||||
transcriptBackfillAvailable(storedId, tailProfile)
|
||||
) {
|
||||
let grew = false
|
||||
await backfillOlderTranscriptPage({
|
||||
storedSessionId: storedId,
|
||||
profile: tailProfile,
|
||||
// Stale-response guard: a session switch remounts/re-keys this view;
|
||||
// checking the live atoms (not captured props) discards a page that
|
||||
// resolves after the user moved on — same pattern as isCurrentResume.
|
||||
isCurrent: () => view.$storedId.get() === storedId && view.$runtimeId.get() === runtimeId,
|
||||
applyOlderPage: olderPage => {
|
||||
const current = view.$messages.get()
|
||||
|
||||
return merged === state.messages ? state : { ...state, messages: merged }
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
if (mergeOlderTranscriptPage(current, olderPage) === current) {
|
||||
return
|
||||
}
|
||||
|
||||
setWindowPages(pages => pages + 1)
|
||||
}, [runtimeId, storedId, tailProfile, view])
|
||||
beforePrepend?.()
|
||||
setWindowPages(pages => pages + 1)
|
||||
sessionTileDelegate()?.updateSession(runtimeId, state => {
|
||||
const merged = mergeOlderTranscriptPage(state.messages, olderPage)
|
||||
grew = merged !== state.messages
|
||||
|
||||
return grew ? { ...state, messages: merged } : state
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Exhaustion and overlapping-only pages have no structural publication.
|
||||
// Do not leave the list waiting for a commit that will never arrive.
|
||||
return grew
|
||||
}
|
||||
|
||||
beforePrepend?.()
|
||||
setWindowPages(pages => pages + 1)
|
||||
|
||||
return true
|
||||
},
|
||||
[runtimeId, storedId, tailProfile, view]
|
||||
)
|
||||
|
||||
const olderAvailable = windowed || restBackfillAvailable
|
||||
|
||||
@@ -383,6 +400,7 @@ const ChatViewContent = memo(function ChatViewContent({
|
||||
modelOptionsOwnerConnectionId,
|
||||
modelOptionsProfile,
|
||||
modelMenuContent,
|
||||
reasoningMenuContent,
|
||||
requestModelOptionsForOwner,
|
||||
onToggleSelectedPin,
|
||||
onDeleteSelectedSession,
|
||||
@@ -421,6 +439,11 @@ const ChatViewContent = memo(function ChatViewContent({
|
||||
const composerSurfaceId = useComposerSurfaceId()
|
||||
const isPrimary = view.kind === 'primary'
|
||||
const activeSessionId = useStore(view.$runtimeId)
|
||||
|
||||
const transcriptStoredSessionId = useStoreSelector($sessionStates, states =>
|
||||
activeSessionId ? (states[activeSessionId]?.storedSessionId ?? null) : null
|
||||
)
|
||||
|
||||
const storedId = useStore(view.$storedId)
|
||||
// Multi-pane dimming: only the focused surface paints at full strength, so
|
||||
// two sessions side by side read as "this one, and that one over there".
|
||||
@@ -514,7 +537,14 @@ const ChatViewContent = memo(function ChatViewContent({
|
||||
// direct nav). Derived in render so the swap reads instantly: the same frame
|
||||
// the id changes we drop the old transcript and show the loader, instead of
|
||||
// waiting for the resume effect (which paints a frame later) to clear them.
|
||||
const routeSessionMismatch = isPrimary ? isRouteSessionMismatch(routedSessionId, selectedSessionId, sessions) : false
|
||||
const routeSessionMismatch = isPrimary
|
||||
? isRouteSessionMismatch(routedSessionId, selectedSessionId, sessions, {
|
||||
activeRuntimeId: activeSessionId,
|
||||
contextSwitching: Boolean(gatewaySwapTarget),
|
||||
messagesEmpty,
|
||||
transcriptStoredSessionId
|
||||
})
|
||||
: false
|
||||
|
||||
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
|
||||
// scratch window, not the full-height empty state. The Appearance toggle
|
||||
@@ -565,7 +595,7 @@ const ChatViewContent = memo(function ChatViewContent({
|
||||
const showChatBar = !loadingSession && !resumeExhausted && !isWatchWindow()
|
||||
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
||||
|
||||
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
||||
const modelOptionsQuery = useQuery<ModelOptionsResult>({
|
||||
queryKey: modelOptionsQueryKey(
|
||||
modelOptionsProfile || activeGatewayProfile,
|
||||
activeSessionId,
|
||||
@@ -586,6 +616,8 @@ const ChatViewContent = memo(function ChatViewContent({
|
||||
[currentModel, currentProvider, modelOptionsQuery.data]
|
||||
)
|
||||
|
||||
const supportsReasoning = currentModelCapabilities(modelOptionsQuery.data, currentProvider, currentModel)?.reasoning
|
||||
|
||||
const chatBarState = useMemo<ChatBarState>(
|
||||
() => ({
|
||||
model: {
|
||||
@@ -594,7 +626,9 @@ const ChatViewContent = memo(function ChatViewContent({
|
||||
canSwitch: gatewayOpen,
|
||||
loading: !gatewayOpen || (!currentModel && !currentProvider),
|
||||
modelMenuContent,
|
||||
quickModels
|
||||
quickModels,
|
||||
reasoningMenuContent,
|
||||
supportsReasoning
|
||||
},
|
||||
tools: {
|
||||
enabled: true,
|
||||
@@ -606,7 +640,16 @@ const ChatViewContent = memo(function ChatViewContent({
|
||||
active: false
|
||||
}
|
||||
}),
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
|
||||
[
|
||||
contextSuggestions,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
gatewayOpen,
|
||||
modelMenuContent,
|
||||
quickModels,
|
||||
reasoningMenuContent,
|
||||
supportsReasoning
|
||||
]
|
||||
)
|
||||
|
||||
// Drop files anywhere in the conversation area, not just on the composer
|
||||
@@ -703,6 +746,7 @@ const ChatViewContent = memo(function ChatViewContent({
|
||||
onCancel={haltRun}
|
||||
onDismissError={onDismissError}
|
||||
onRestoreToMessage={onRestoreToMessage}
|
||||
scrollProfile={modelOptionsProfile || activeGatewayProfile}
|
||||
sessionId={activeSessionId}
|
||||
sessionKey={threadKey}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { PaneStripGlyph } from '@/components/ui/pane-tab'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { isSubmitEnter } from '@/lib/ime'
|
||||
import { ANNOTATE_BLUE } from '@/lib/preview-annotate'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -191,7 +192,7 @@ export function PreviewBrowserBar({
|
||||
event.currentTarget.select()
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
if (isSubmitEnter(event)) {
|
||||
commit(event.currentTarget.value)
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
|
||||
@@ -84,17 +84,14 @@ describe('RealProfileConsentDialog', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: promptCopy.enable }))
|
||||
})
|
||||
|
||||
// Saves the WHOLE merged record with only use_real_profile added — the
|
||||
// same shape the Capabilities toggle writes, through the same cache, so
|
||||
// the existing toggle flips on without a refetch.
|
||||
expect(mocks.save).toHaveBeenCalledWith(
|
||||
{
|
||||
browser: { allow_private_urls: false, use_real_profile: true },
|
||||
model: { provider: 'nous' }
|
||||
},
|
||||
undefined
|
||||
)
|
||||
expect(mocks.cache).toHaveBeenCalledWith(mocks.save.mock.calls[0][0])
|
||||
// Saves ONLY the toggled key (PUT deep-merges) — the same shape the
|
||||
// Capabilities toggle writes — while the shared cache gets the merged
|
||||
// record so the existing toggle flips on without a refetch.
|
||||
expect(mocks.save).toHaveBeenCalledWith({ browser: { use_real_profile: true } }, undefined)
|
||||
expect(mocks.cache).toHaveBeenCalledWith({
|
||||
browser: { allow_private_urls: false, use_real_profile: true },
|
||||
model: { provider: 'nous' }
|
||||
})
|
||||
expect(mocks.notify).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -78,7 +78,9 @@ export function RealProfileConsentDialog({ tabId }: RealProfileConsentDialogProp
|
||||
setConfig(next)
|
||||
|
||||
try {
|
||||
await saveHermesConfigRecord(next)
|
||||
// Sparse patch: PUT /api/config deep-merges, and echoing the cached
|
||||
// snapshot would overwrite keys other surfaces changed since it loaded.
|
||||
await saveHermesConfigRecord({ browser: { use_real_profile: true } })
|
||||
notify({ kind: 'info', title: copy.enabledTitle, message: copy.enabledMessage })
|
||||
} catch (err) {
|
||||
setConfig(config)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user