mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
* feat(desktop): configure repository discovery
* fix(config): preserve additive default migration
* fix(desktop): stabilize session-actions-menu gateway mock for repo-scan subscribe
projects.ts now runs $gateway.subscribe(syncReposScanning) at module load, and
nanostores fires the subscriber synchronously. session-actions-menu.test.ts
reaches projects.ts transitively via the session store but mocked
@/store/gateway without $gateway, crashing the whole desktop vitest suite
("No \ export is defined"). Simply adding $gateway: atom(null) exposed a
second issue: the synchronous subscriber calls the mock's activeGateway()
during the transitive import, before the module-level const initializes (TDZ).
Hoist the mock fns via vi.hoisted() so activeGateway is defined before the
hoisted vi.mock factory runs, and add $gateway: atom(null) to the mock. Mirrors
the self-contained mock pattern already used in projects.test.ts. Also maps the
PR author's commit email for attribution.
Supersedes #67630; incorporates review feedback from that PR.
Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>
---------
Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>
Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com>
This commit is contained in:
parent
d3b0e61429
commit
d355e0e71d
22 changed files with 957 additions and 109 deletions
76
apps/desktop/electron/git-repo-scan.test.ts
Normal file
76
apps/desktop/electron/git-repo-scan.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { normalizeRepoScanPath, repoScanPathIsWithin, scanGitRepos } from './git-repo-scan'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function tempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-repo-scan-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function makeRepo(root: string, valid = true): void {
|
||||
fs.mkdirSync(path.join(root, '.git'), { recursive: true })
|
||||
if (valid) {
|
||||
fs.writeFileSync(path.join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n')
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('scanGitRepos', () => {
|
||||
it('does not read the filesystem when discovery is disabled', async () => {
|
||||
const read = vi.spyOn(fs.promises, 'readdir')
|
||||
|
||||
await expect(scanGitRepos([], { enabled: false })).resolves.toEqual([])
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scans only configured roots and excludes complete subtrees', async () => {
|
||||
const root = tempDir()
|
||||
const included = path.join(root, 'included')
|
||||
const excluded = path.join(root, 'excluded')
|
||||
const invalid = path.join(root, 'invalid')
|
||||
makeRepo(included)
|
||||
makeRepo(excluded)
|
||||
makeRepo(invalid, false)
|
||||
|
||||
await expect(scanGitRepos([root], { enabled: true, excludePaths: [excluded], maxDepth: 2 })).resolves.toEqual([
|
||||
{ label: 'included', root: included }
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates overlapping roots', async () => {
|
||||
const root = tempDir()
|
||||
const repo = path.join(root, 'repo')
|
||||
makeRepo(repo)
|
||||
|
||||
const result = await scanGitRepos([root, repo], { enabled: true })
|
||||
expect(result).toEqual([{ label: 'repo', root: repo }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('repository scan path normalization', () => {
|
||||
it('expands tilde and resolves relative paths from home', () => {
|
||||
expect(normalizeRepoScanPath('~/src', { homeDir: '/Users/rudi', platform: 'darwin' })?.value).toBe(
|
||||
'/Users/rudi/src'
|
||||
)
|
||||
expect(normalizeRepoScanPath('src', { homeDir: '/Users/rudi', platform: 'linux' })?.value).toBe('/Users/rudi/src')
|
||||
})
|
||||
|
||||
it('uses segment-aware, case-insensitive containment on Windows', () => {
|
||||
const options = { homeDir: 'C:\\Users\\Rudi', platform: 'win32' as const }
|
||||
expect(repoScanPathIsWithin('c:\\SRC\\Fever\\repo', 'C:\\src\\fever', options)).toBe(true)
|
||||
expect(repoScanPathIsWithin('C:\\src\\feverish', 'C:\\src\\fever', options)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,32 +1,76 @@
|
|||
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
|
||||
// — no native addon, so it just works for anyone who pulls main (no
|
||||
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
|
||||
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
|
||||
// first scan stays fast. Results are cached by the backend after the first run.
|
||||
// Repo-first discovery: walk bounded roots for Git repositories using only
|
||||
// Node's fs APIs. Electron owns this machine-local capability; the renderer
|
||||
// supplies the profile-scoped policy from Hermes config.
|
||||
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const fsp = fs.promises
|
||||
|
||||
// Shallow on purpose: real projects live a few levels under home
|
||||
// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always
|
||||
// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos
|
||||
// you actually use but keep deeper still surface via session-derived discovery,
|
||||
// so this only prunes noise, never repos with history.
|
||||
const DEFAULT_MAX_DEPTH = 3
|
||||
const MAX_CONCURRENCY = 32
|
||||
|
||||
// Big trees that are never themselves repos and would waste the walk. Anything
|
||||
// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this
|
||||
// only needs the non-hidden heavyweights.
|
||||
const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv'])
|
||||
|
||||
async function mapLimit(items, limit, fn) {
|
||||
export interface RepoScanOptions {
|
||||
maxDepth?: number
|
||||
enabled?: boolean
|
||||
excludePaths?: string[]
|
||||
}
|
||||
|
||||
export interface RepoScanPathOptions {
|
||||
homeDir?: string
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
interface NormalizedScanPath {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
function pathApiFor(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
|
||||
return platform === 'win32' ? path.win32 : path.posix
|
||||
}
|
||||
|
||||
export function normalizeRepoScanPath(rawPath: string, options: RepoScanPathOptions = {}): NormalizedScanPath | null {
|
||||
const platform = options.platform ?? process.platform
|
||||
const homeDir = options.homeDir ?? os.homedir()
|
||||
const pathApi = pathApiFor(platform)
|
||||
const raw = String(rawPath ?? '').trim()
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
let expanded = raw
|
||||
if (raw === '~') {
|
||||
expanded = homeDir
|
||||
} else if (raw.startsWith('~/') || raw.startsWith('~\\')) {
|
||||
expanded = pathApi.join(homeDir, raw.slice(2))
|
||||
}
|
||||
|
||||
const absolute = pathApi.isAbsolute(expanded) ? expanded : pathApi.resolve(homeDir, expanded)
|
||||
const value = pathApi.normalize(absolute)
|
||||
const key = platform === 'win32' ? value.toLocaleLowerCase('en-US') : value
|
||||
return { key, value }
|
||||
}
|
||||
|
||||
export function repoScanPathIsWithin(candidate: string, parent: string, options: RepoScanPathOptions = {}): boolean {
|
||||
const platform = options.platform ?? process.platform
|
||||
const pathApi = pathApiFor(platform)
|
||||
const candidatePath = normalizeRepoScanPath(candidate, options)
|
||||
const parentPath = normalizeRepoScanPath(parent, options)
|
||||
if (!candidatePath || !parentPath) {
|
||||
return false
|
||||
}
|
||||
const relative = pathApi.relative(parentPath.key, candidatePath.key)
|
||||
return (
|
||||
relative === '' || (relative !== '..' && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative))
|
||||
)
|
||||
}
|
||||
|
||||
async function mapLimit<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let cursor = 0
|
||||
|
||||
async function worker() {
|
||||
async function worker(): Promise<void> {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
|
|
@ -34,63 +78,75 @@ async function mapLimit(items, limit, fn) {
|
|||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker))
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
|
||||
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
|
||||
* Scan roots for Git repositories. An empty root list preserves the historical
|
||||
* home-directory scan. Disabled discovery returns before resolving home or
|
||||
* reading the filesystem.
|
||||
*/
|
||||
async function scanGitRepos(roots, options: any = {}) {
|
||||
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
|
||||
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
|
||||
const found = new Map()
|
||||
export async function scanGitRepos(roots: string[], options: RepoScanOptions = {}) {
|
||||
if (options.enabled === false) {
|
||||
return []
|
||||
}
|
||||
|
||||
async function walk(dir, depth) {
|
||||
if (depth > maxDepth) {
|
||||
const maxDepthValue = Number(options.maxDepth)
|
||||
const maxDepth = Number.isFinite(maxDepthValue) && maxDepthValue >= 0 ? maxDepthValue : DEFAULT_MAX_DEPTH
|
||||
const pathOptions: RepoScanPathOptions = {}
|
||||
const requestedRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
|
||||
const searchRoots = [
|
||||
...new Map(
|
||||
requestedRoots
|
||||
.map(root => normalizeRepoScanPath(root, pathOptions))
|
||||
.filter((entry): entry is NormalizedScanPath => entry !== null)
|
||||
.map(entry => [entry.key, entry.value])
|
||||
).values()
|
||||
]
|
||||
const exclusions = (options.excludePaths ?? [])
|
||||
.map(excluded => normalizeRepoScanPath(excluded, pathOptions))
|
||||
.filter((entry): entry is NormalizedScanPath => entry !== null)
|
||||
const found = new Map<string, { root: string; label: string }>()
|
||||
|
||||
function isExcluded(candidate: string): boolean {
|
||||
return exclusions.some(excluded => repoScanPathIsWithin(candidate, excluded.value, pathOptions))
|
||||
}
|
||||
|
||||
async function walk(dir: string, depth: number): Promise<void> {
|
||||
if (depth > maxDepth || isExcluded(dir)) {
|
||||
return
|
||||
}
|
||||
|
||||
let entries
|
||||
|
||||
let entries: fs.Dirent[]
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return // unreadable / permission denied
|
||||
}
|
||||
|
||||
// A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git`
|
||||
// FILE is a linked worktree or submodule — those belong to their parent
|
||||
// repo as lanes, not as separate projects, so we don't list them (and we
|
||||
// keep descending in case a real repo sits deeper). This is what kills the
|
||||
// worktree/eval-repo duplicate explosion.
|
||||
if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) {
|
||||
const root = dir.replace(/[/\\]+$/, '')
|
||||
found.set(root, path.basename(root) || root)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const subdirs = []
|
||||
|
||||
for (const entry of entries) {
|
||||
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
|
||||
// known heavy trees.
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) {
|
||||
continue
|
||||
const gitDir = entries.find(entry => entry.name === '.git' && entry.isDirectory())
|
||||
if (gitDir) {
|
||||
try {
|
||||
await fsp.access(path.join(dir, '.git', 'HEAD'), fs.constants.R_OK)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
subdirs.push(path.join(dir, entry.name))
|
||||
const normalized = normalizeRepoScanPath(dir, pathOptions)
|
||||
if (normalized) {
|
||||
found.set(normalized.key, {
|
||||
root: normalized.value,
|
||||
label: path.basename(normalized.value) || normalized.value
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1))
|
||||
const subdirs = entries
|
||||
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !JUNK_DIRS.has(entry.name))
|
||||
.map(entry => path.join(dir, entry.name))
|
||||
await mapLimit(subdirs, MAX_CONCURRENCY, subdir => walk(subdir, depth + 1))
|
||||
}
|
||||
|
||||
await mapLimit(searchRoots.map(root => String(root || '').trim()).filter(Boolean), MAX_CONCURRENCY, root =>
|
||||
walk(root, 0)
|
||||
)
|
||||
|
||||
return [...found.entries()].map(([root, label]) => ({ label, root }))
|
||||
await mapLimit(searchRoots, MAX_CONCURRENCY, root => walk(root, 0))
|
||||
return [...found.values()]
|
||||
}
|
||||
|
||||
export { scanGitRepos }
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { atom } from 'nanostores'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
|
||||
|
|
@ -10,9 +11,19 @@ import { renameSessionPreferringRpc } from './session-actions-menu'
|
|||
// must route the ACTIVE row through the session.title RPC (runtime id), which
|
||||
// persists the row on demand, and otherwise fall back to REST.
|
||||
|
||||
const renameSession = vi.fn(async () => ({ ok: true, title: 'rest-title' }))
|
||||
const request = vi.fn(async () => ({ title: 'rpc-title' }) as never)
|
||||
const activeGateway = vi.fn<() => { request: typeof request } | null>(() => ({ request }))
|
||||
// Hoisted so the vi.mock factories below (which vitest lifts to the top of the
|
||||
// module) can reference these before the module body runs. This matters because
|
||||
// projects.ts subscribes to $gateway at import and nanostores fires the
|
||||
// subscriber synchronously — that reaches the @/store/gateway mock's
|
||||
// activeGateway() during the transitive import on line 4, before a plain
|
||||
// module-level const would be initialized (temporal dead zone).
|
||||
const { renameSession, request, activeGateway } = vi.hoisted(() => ({
|
||||
renameSession: vi.fn(async () => ({ ok: true, title: 'rest-title' })),
|
||||
request: vi.fn(async () => ({ title: 'rpc-title' }) as never),
|
||||
activeGateway: vi.fn<() => { request: unknown } | null>(() => ({ request: undefined }))
|
||||
}))
|
||||
// Wire activeGateway's default return to the shared request mock now that it exists.
|
||||
activeGateway.mockReturnValue({ request })
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
renameSession: (...args: unknown[]) => renameSession(...(args as [])),
|
||||
|
|
@ -23,6 +34,11 @@ vi.mock('@/hermes', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/store/gateway', () => ({
|
||||
// projects.ts subscribes to $gateway at module load (its repo-scan sync fires
|
||||
// immediately), pulled in transitively via the session store. Provide a real
|
||||
// atom plus the hoisted activeGateway so the synchronous subscriber doesn't
|
||||
// throw on an incomplete mock or hit an uninitialized reference.
|
||||
$gateway: atom(null),
|
||||
activeGateway: () => activeGateway()
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/
|
|||
import { useI18n } from '@/i18n'
|
||||
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
|
||||
|
|
@ -76,6 +77,7 @@ export function ConfigSettings({
|
|||
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
|
||||
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
|
||||
const saveVersionRef = useRef(0)
|
||||
const savedDiscoverySignatureRef = useRef<string | undefined>(undefined)
|
||||
const [saveVersion, setSaveVersion] = useState(0)
|
||||
|
||||
// Seed the local draft once, the first time the shared record lands.
|
||||
|
|
@ -85,6 +87,7 @@ export function ConfigSettings({
|
|||
useEffect(() => {
|
||||
if (loadedConfig && !configSeeded.current) {
|
||||
configSeeded.current = true
|
||||
savedDiscoverySignatureRef.current = repoDiscoveryPolicySignature(repoDiscoveryPolicyFromConfig(loadedConfig))
|
||||
setConfig(loadedConfig)
|
||||
}
|
||||
}, [loadedConfig])
|
||||
|
|
@ -95,6 +98,7 @@ export function ConfigSettings({
|
|||
// the pending debounced autosave is cancelled by its effect cleanup.
|
||||
useOnProfileSwitch(() => {
|
||||
configSeeded.current = false
|
||||
savedDiscoverySignatureRef.current = undefined
|
||||
setConfig(null)
|
||||
saveVersionRef.current = 0
|
||||
setSaveVersion(0)
|
||||
|
|
@ -132,12 +136,20 @@ export function ConfigSettings({
|
|||
const t = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await saveHermesConfig(config)
|
||||
const result = await saveHermesConfig(config)
|
||||
if (!result.ok) {
|
||||
throw new Error(c.autosaveFailed)
|
||||
}
|
||||
// Mirror the saved record into the shared cache so MCP/model surfaces
|
||||
// reflect the edit without their own refetch.
|
||||
setHermesConfigCache(config)
|
||||
|
||||
if (saveVersionRef.current === v) {
|
||||
const discoverySignature = repoDiscoveryPolicySignature(repoDiscoveryPolicyFromConfig(config))
|
||||
if (savedDiscoverySignatureRef.current !== discoverySignature) {
|
||||
savedDiscoverySignatureRef.current = discoverySignature
|
||||
await scanAndRecordRepos(true)
|
||||
}
|
||||
onConfigSaved?.()
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -390,6 +390,11 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
|
|||
personality: 'Personality',
|
||||
showReasoning: 'Reasoning Blocks'
|
||||
},
|
||||
desktop: {
|
||||
repoScanEnabled: 'Automatic Repository Discovery',
|
||||
repoScanRoots: 'Repository Discovery Roots',
|
||||
repoScanExcludePaths: 'Excluded Repository Paths'
|
||||
},
|
||||
agent: {
|
||||
maxTurns: 'Max Agent Steps',
|
||||
imageInputMode: 'Image Attachments',
|
||||
|
|
@ -551,6 +556,11 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
|
|||
personality: 'Default assistant style for new sessions.',
|
||||
showReasoning: 'Show reasoning sections when the backend provides them.'
|
||||
},
|
||||
desktop: {
|
||||
repoScanEnabled: 'Scan local folders for Git repositories to show in Projects.',
|
||||
repoScanRoots: 'Folders to scan. Leave empty to scan your home directory.',
|
||||
repoScanExcludePaths: 'Folders and their descendants to skip during repository discovery.'
|
||||
},
|
||||
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
|
||||
agent: {
|
||||
imageInputMode: 'Controls how image attachments are sent to the model.',
|
||||
|
|
@ -645,6 +655,9 @@ export const SECTIONS: DesktopConfigSection[] = [
|
|||
icon: Monitor,
|
||||
keys: [
|
||||
'terminal.cwd',
|
||||
'desktop.repo_scan_enabled',
|
||||
'desktop.repo_scan_roots',
|
||||
'desktop.repo_scan_exclude_paths',
|
||||
'code_execution.mode',
|
||||
'terminal.persistent_shell',
|
||||
'terminal.env_passthrough',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
||||
import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy'
|
||||
import {
|
||||
enumOptionsFor,
|
||||
|
|
@ -15,6 +16,20 @@ import {
|
|||
} from './helpers'
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('surfaces repository discovery config in Workspace with user-facing copy', () => {
|
||||
const workspace = SECTIONS.find(section => section.id === 'workspace')
|
||||
|
||||
expect(workspace?.keys).toEqual(
|
||||
expect.arrayContaining([
|
||||
'desktop.repo_scan_enabled',
|
||||
'desktop.repo_scan_roots',
|
||||
'desktop.repo_scan_exclude_paths'
|
||||
])
|
||||
)
|
||||
expect(fieldCopyForSchemaKey(FIELD_LABELS, 'desktop.repo_scan_enabled')).toBeTruthy()
|
||||
expect(fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, 'desktop.repo_scan_exclude_paths')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lists the desktop memory provider options in their declared order', () => {
|
||||
const options = enumOptionsFor('memory.provider', '', {})
|
||||
|
||||
|
|
|
|||
5
apps/desktop/src/global.d.ts
vendored
5
apps/desktop/src/global.d.ts
vendored
|
|
@ -175,7 +175,10 @@ declare global {
|
|||
createPr: (repoPath: string) => Promise<{ url: string }>
|
||||
}
|
||||
// Repo-first discovery: scan bounded roots for git repos (depth-capped).
|
||||
scanRepos: (roots: string[], options?: { maxDepth?: number }) => Promise<{ root: string; label: string }[]>
|
||||
scanRepos: (
|
||||
roots: string[],
|
||||
options?: { maxDepth?: number; enabled?: boolean; excludePaths?: string[] }
|
||||
) => Promise<{ root: string; label: string }[]>
|
||||
}
|
||||
terminal: {
|
||||
/** Best-effort current working directory of the live PTY child (POSIX
|
||||
|
|
|
|||
|
|
@ -229,8 +229,9 @@ export function setApiRequestProfile(profile: null | string): void {
|
|||
_apiProfile = profile || null
|
||||
}
|
||||
|
||||
function profileScoped(): { profile?: string } {
|
||||
return _apiProfile ? { profile: _apiProfile } : {}
|
||||
function profileScoped(profile?: null | string): { profile?: string } {
|
||||
const selected = profile === undefined ? _apiProfile : profile
|
||||
return selected ? { profile: selected } : {}
|
||||
}
|
||||
|
||||
/** Options for a plugin REST call — mirrors the app's own `hermesDesktop.api`
|
||||
|
|
@ -649,9 +650,9 @@ export function getLogs(params: {
|
|||
})
|
||||
}
|
||||
|
||||
export function getHermesConfig(): Promise<HermesConfig> {
|
||||
export function getHermesConfig(profile?: string): Promise<HermesConfig> {
|
||||
return window.hermesDesktop.api<HermesConfig>({
|
||||
...profileScoped(),
|
||||
...profileScoped(profile),
|
||||
path: '/api/config',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
|
|
|
|||
|
|
@ -378,6 +378,11 @@ export const ja = defineLocale({
|
|||
personality: '人格',
|
||||
showReasoning: '推論ブロック'
|
||||
},
|
||||
desktop: {
|
||||
repoScanEnabled: 'リポジトリの自動検出',
|
||||
repoScanRoots: 'リポジトリの検索ルート',
|
||||
repoScanExcludePaths: '除外するリポジトリパス'
|
||||
},
|
||||
agent: {
|
||||
maxTurns: '最大エージェントステップ',
|
||||
imageInputMode: '画像添付',
|
||||
|
|
@ -533,6 +538,11 @@ export const ja = defineLocale({
|
|||
personality: '新しいセッションのデフォルトのアシスタントスタイルです。',
|
||||
showReasoning: 'バックエンドが推論内容を提供したときに表示します。'
|
||||
},
|
||||
desktop: {
|
||||
repoScanEnabled: 'ローカルフォルダを検索して Git リポジトリをプロジェクトに表示します。',
|
||||
repoScanRoots: '検索するフォルダです。空の場合はホームディレクトリを検索します。',
|
||||
repoScanExcludePaths: 'リポジトリ検出時に除外するフォルダとその配下です。'
|
||||
},
|
||||
timezone:
|
||||
'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。',
|
||||
agent: {
|
||||
|
|
|
|||
|
|
@ -367,6 +367,11 @@ export const zhHant = defineLocale({
|
|||
personality: '人格',
|
||||
showReasoning: '推理區塊'
|
||||
},
|
||||
desktop: {
|
||||
repoScanEnabled: '自動探索程式碼儲存庫',
|
||||
repoScanRoots: '程式碼儲存庫掃描根目錄',
|
||||
repoScanExcludePaths: '排除的程式碼儲存庫路徑'
|
||||
},
|
||||
agent: {
|
||||
maxTurns: '最大代理步數',
|
||||
imageInputMode: '圖片附件',
|
||||
|
|
@ -522,6 +527,11 @@ export const zhHant = defineLocale({
|
|||
personality: '新工作階段的預設助手風格。',
|
||||
showReasoning: '後端提供推理內容時顯示該區塊。'
|
||||
},
|
||||
desktop: {
|
||||
repoScanEnabled: '掃描本機資料夾,並在「專案」中顯示 Git 程式碼儲存庫。',
|
||||
repoScanRoots: '要掃描的資料夾。留空時掃描主目錄。',
|
||||
repoScanExcludePaths: '探索程式碼儲存庫時略過這些資料夾及其子目錄。'
|
||||
},
|
||||
timezone: 'Hermes 需要本機時間上下文時使用。留空則使用系統時區。',
|
||||
agent: {
|
||||
imageInputMode: '控制圖片附件如何傳送給模型。',
|
||||
|
|
|
|||
|
|
@ -478,6 +478,11 @@ export const zh: Translations = {
|
|||
personality: '人格',
|
||||
showReasoning: '推理过程块'
|
||||
},
|
||||
desktop: {
|
||||
repoScanEnabled: '自动发现代码仓库',
|
||||
repoScanRoots: '代码仓库扫描根目录',
|
||||
repoScanExcludePaths: '排除的代码仓库路径'
|
||||
},
|
||||
agent: {
|
||||
maxTurns: '最大智能体步数',
|
||||
imageInputMode: '图片附件',
|
||||
|
|
@ -633,6 +638,11 @@ export const zh: Translations = {
|
|||
personality: '新会话的默认助手风格。',
|
||||
showReasoning: '当后端提供推理内容时予以显示。'
|
||||
},
|
||||
desktop: {
|
||||
repoScanEnabled: '扫描本地文件夹,并在“项目”中显示 Git 代码仓库。',
|
||||
repoScanRoots: '要扫描的文件夹。留空时扫描主目录。',
|
||||
repoScanExcludePaths: '发现代码仓库时跳过这些文件夹及其子目录。'
|
||||
},
|
||||
timezone: '当 Hermes 需要本地时间上下文时使用。留空则使用系统时区。',
|
||||
agent: {
|
||||
imageInputMode: '控制图片附件如何发送给模型。',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { atom } from 'nanostores'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
|
||||
import { $sidebarAgentsGrouped } from '@/store/layout'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
|
||||
import {
|
||||
$activeProjectId,
|
||||
|
|
@ -17,7 +19,9 @@ import {
|
|||
pickProjectFolder,
|
||||
projectNameForCwd,
|
||||
refreshProjects,
|
||||
refreshWorktrees
|
||||
refreshProjectTree,
|
||||
refreshWorktrees,
|
||||
scanAndRecordRepos
|
||||
} from './projects'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
|
|
@ -36,10 +40,20 @@ vi.mock('@/lib/desktop-fs', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/store/gateway', () => ({
|
||||
$gateway: atom(null),
|
||||
activeGateway: vi.fn(),
|
||||
ensureActiveGatewayOpen: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/desktop-git', () => ({ desktopGit: vi.fn() }))
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getHermesConfig: vi.fn(),
|
||||
getProfiles: vi.fn(),
|
||||
setApiRequestProfile: vi.fn(),
|
||||
STARTUP_REQUEST_TIMEOUT_MS: 1000
|
||||
}))
|
||||
|
||||
const fs = await import('@/lib/desktop-fs')
|
||||
const desktopDefaultCwd = vi.mocked(fs.desktopDefaultCwd)
|
||||
const isDesktopFsRemoteMode = vi.mocked(fs.isDesktopFsRemoteMode)
|
||||
|
|
@ -47,6 +61,13 @@ const selectDesktopPaths = vi.mocked(fs.selectDesktopPaths)
|
|||
|
||||
const gw = await import('@/store/gateway')
|
||||
const activeGateway = vi.mocked(gw.activeGateway)
|
||||
const gatewayAtom = gw.$gateway
|
||||
|
||||
const git = await import('@/lib/desktop-git')
|
||||
const desktopGit = vi.mocked(git.desktopGit)
|
||||
|
||||
const hermes = await import('@/hermes')
|
||||
const getHermesConfig = vi.mocked(hermes.getHermesConfig)
|
||||
const notifications = await import('@/store/notifications')
|
||||
const notify = vi.mocked(notifications.notify)
|
||||
|
||||
|
|
@ -259,3 +280,124 @@ describe('projects RPC capability', () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('repository discovery policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
$activeGatewayProfile.set('default')
|
||||
isDesktopFsRemoteMode.mockReturnValue(false)
|
||||
})
|
||||
|
||||
function gatewayWith(request: ReturnType<typeof vi.fn>) {
|
||||
const gateway = { connectionState: 'open', request }
|
||||
activeGateway.mockReturnValue(gateway as never)
|
||||
gatewayAtom.set(gateway as never)
|
||||
return gateway
|
||||
}
|
||||
|
||||
it('records disabled policy without invoking the filesystem scanner', async () => {
|
||||
const request = vi.fn(async (method: string) =>
|
||||
method === 'projects.tree'
|
||||
? { active_id: null, projects: [], scoped_session_ids: [] }
|
||||
: { accepted: false, repos: [] }
|
||||
)
|
||||
gatewayWith(request)
|
||||
const scanRepos = vi.fn()
|
||||
desktopGit.mockReturnValue({ scanRepos } as never)
|
||||
getHermesConfig.mockResolvedValue({
|
||||
desktop: {
|
||||
repo_scan_enabled: false,
|
||||
repo_scan_exclude_paths: [],
|
||||
repo_scan_roots: []
|
||||
}
|
||||
})
|
||||
|
||||
await scanAndRecordRepos()
|
||||
|
||||
expect(scanRepos).not.toHaveBeenCalled()
|
||||
expect(request).toHaveBeenCalledWith('projects.record_repos', {
|
||||
discovery_policy: { enabled: false, exclude_paths: [], roots: [] },
|
||||
repos: []
|
||||
})
|
||||
})
|
||||
|
||||
it('passes custom roots and exclusions to Electron and records on the origin gateway', async () => {
|
||||
const request = vi.fn(async (method: string) =>
|
||||
method === 'projects.tree'
|
||||
? { active_id: null, projects: [], scoped_session_ids: [] }
|
||||
: { accepted: true, repos: [] }
|
||||
)
|
||||
gatewayWith(request)
|
||||
const scanRepos = vi.fn().mockResolvedValue([{ label: 'repo', root: '/work/repo' }])
|
||||
desktopGit.mockReturnValue({ scanRepos } as never)
|
||||
getHermesConfig.mockResolvedValue({
|
||||
desktop: {
|
||||
repo_scan_enabled: true,
|
||||
repo_scan_exclude_paths: ['/work/vendor'],
|
||||
repo_scan_roots: ['/work']
|
||||
}
|
||||
})
|
||||
|
||||
await scanAndRecordRepos()
|
||||
|
||||
expect(getHermesConfig).toHaveBeenCalledWith('default')
|
||||
expect(scanRepos).toHaveBeenCalledWith(['/work'], {
|
||||
enabled: true,
|
||||
excludePaths: ['/work/vendor']
|
||||
})
|
||||
expect(request).toHaveBeenCalledWith('projects.record_repos', {
|
||||
discovery_policy: {
|
||||
enabled: true,
|
||||
exclude_paths: ['/work/vendor'],
|
||||
roots: ['/work']
|
||||
},
|
||||
repos: [{ label: 'repo', root: '/work/repo' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not scan the local filesystem for remote connections', async () => {
|
||||
isDesktopFsRemoteMode.mockReturnValue(true)
|
||||
const scanRepos = vi.fn()
|
||||
desktopGit.mockReturnValue({ scanRepos } as never)
|
||||
|
||||
await scanAndRecordRepos(true)
|
||||
|
||||
expect(scanRepos).not.toHaveBeenCalled()
|
||||
expect(getHermesConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('project tree profile isolation', () => {
|
||||
it('does not publish a late response from the previous profile', async () => {
|
||||
let resolveA: ((value: unknown) => void) | undefined
|
||||
const responseA = new Promise(resolve => {
|
||||
resolveA = resolve
|
||||
})
|
||||
const gatewayA = { connectionState: 'open', request: vi.fn(() => responseA) }
|
||||
const gatewayB = {
|
||||
connectionState: 'open',
|
||||
request: vi.fn().mockResolvedValue({
|
||||
active_id: null,
|
||||
projects: [{ id: 'profile-b', label: 'Profile B', path: null, repos: [], sessionCount: 0 }],
|
||||
scoped_session_ids: []
|
||||
})
|
||||
}
|
||||
let current = gatewayA
|
||||
activeGateway.mockImplementation(() => current as never)
|
||||
gatewayAtom.set(gatewayA as never)
|
||||
|
||||
const pendingA = refreshProjectTree()
|
||||
current = gatewayB
|
||||
$activeGatewayProfile.set('profile-b')
|
||||
gatewayAtom.set(gatewayB as never)
|
||||
await refreshProjectTree()
|
||||
resolveA?.({
|
||||
active_id: null,
|
||||
projects: [{ id: 'profile-a', label: 'Profile A', path: null, repos: [], sessionCount: 0 }],
|
||||
scoped_session_ids: []
|
||||
})
|
||||
await pendingA
|
||||
|
||||
expect($projectTree.get().map(project => project.id)).toEqual(['profile-b'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@ import { atom } from 'nanostores'
|
|||
|
||||
import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
|
||||
import type { HermesGitBaseBranch, HermesGitBranch } from '@/global'
|
||||
import { getHermesConfig, type HermesGateway } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { desktopDefaultCwd, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { desktopDefaultCwd, isDesktopFsRemoteMode, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { desktopGit } from '@/lib/desktop-git'
|
||||
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
|
||||
import { persistentAtom } from '@/lib/persisted'
|
||||
import { activeGateway, ensureActiveGatewayOpen } from '@/store/gateway'
|
||||
import { $gateway, activeGateway, ensureActiveGatewayOpen } from '@/store/gateway'
|
||||
import { setSidebarAgentsGrouped } from '@/store/layout'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestFreshSession } from '@/store/profile'
|
||||
import { $activeGatewayProfile, requestFreshSession } from '@/store/profile'
|
||||
import { $selectedStoredSessionId, $sessions, sessionMatchesStoredId, workspaceCwdForNewSession } from '@/store/session'
|
||||
import type { ProjectInfo, ProjectsPayload } from '@/types/hermes'
|
||||
|
||||
|
|
@ -265,6 +266,31 @@ async function gatewayRequest<T>(method: string, params: Record<string, unknown>
|
|||
return gateway.request<T>(method, params)
|
||||
}
|
||||
|
||||
async function gatewayRequestOn<T>(
|
||||
gateway: HermesGateway,
|
||||
method: string,
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
return gateway.request<T>(method, params)
|
||||
}
|
||||
|
||||
interface ActiveProjectsContext {
|
||||
gateway: HermesGateway
|
||||
profile: string
|
||||
}
|
||||
|
||||
async function activeProjectsContext(): Promise<ActiveProjectsContext> {
|
||||
const profile = $activeGatewayProfile.get() || 'default'
|
||||
let gateway = activeGateway()
|
||||
if (!gateway || gateway.connectionState !== 'open') {
|
||||
gateway = await ensureActiveGatewayOpen()
|
||||
}
|
||||
if (!gateway || gateway !== activeGateway() || profile !== ($activeGatewayProfile.get() || 'default')) {
|
||||
throw new Error('Active Hermes profile changed while connecting')
|
||||
}
|
||||
return { gateway, profile }
|
||||
}
|
||||
|
||||
function applyPayload(payload: ProjectsPayload): void {
|
||||
$projects.set(payload.projects ?? [])
|
||||
$activeProjectId.set(payload.active_id ?? null)
|
||||
|
|
@ -288,40 +314,52 @@ interface ProjectTreePayload {
|
|||
scoped_session_ids: string[]
|
||||
}
|
||||
|
||||
// Pull the authoritative project tree (overview structure + counts + preview
|
||||
// sessions + the scoped-session-id set). Best-effort: a failure leaves the
|
||||
// cached tree intact so the sidebar doesn't flicker.
|
||||
export async function refreshProjectTree(): Promise<void> {
|
||||
$projectTreeLoading.set(true)
|
||||
let projectTreeRefreshGeneration = 0
|
||||
|
||||
async function refreshProjectTreeOn(gateway: HermesGateway): Promise<void> {
|
||||
const generation = ++projectTreeRefreshGeneration
|
||||
if (activeGateway() === gateway) {
|
||||
$projectTreeLoading.set(true)
|
||||
}
|
||||
try {
|
||||
const res = await gatewayRequest<ProjectTreePayload>('projects.tree', { preview_limit: 3 })
|
||||
// The flat Sessions list shows everything; scoped ids are only used here to
|
||||
// reconcile the optimistic eviction layer against what the server still lists.
|
||||
const scoped = new Set(res.scoped_session_ids ?? [])
|
||||
const res = await gatewayRequestOn<ProjectTreePayload>(gateway, 'projects.tree', {
|
||||
preview_limit: 3
|
||||
})
|
||||
if (generation !== projectTreeRefreshGeneration || activeGateway() !== gateway) {
|
||||
return
|
||||
}
|
||||
|
||||
const scoped = new Set(res.scoped_session_ids ?? [])
|
||||
$projectTree.set(res.projects ?? [])
|
||||
$activeProjectId.set(res.active_id ?? null)
|
||||
|
||||
// Reconcile the optimistic eviction layer against the fresh snapshot: keep
|
||||
// evicting ids the server still lists (delete in flight) and drop the rest
|
||||
// (server caught up), so the set can't grow unbounded across a long session.
|
||||
const tombstones = $removedSessionIds.get()
|
||||
|
||||
if (tombstones.size) {
|
||||
const pending = new Set([...tombstones].filter(id => scoped.has(id)))
|
||||
|
||||
if (pending.size !== tombstones.size) {
|
||||
$removedSessionIds.set(pending)
|
||||
}
|
||||
}
|
||||
|
||||
markProjectsRpcSuccess()
|
||||
} catch (err) {
|
||||
markProjectsRpcFailure(err)
|
||||
// Backend may not be ready; keep the last known tree.
|
||||
if (activeGateway() === gateway) {
|
||||
markProjectsRpcFailure(err)
|
||||
}
|
||||
} finally {
|
||||
$projectTreeLoading.set(false)
|
||||
if (generation === projectTreeRefreshGeneration && activeGateway() === gateway) {
|
||||
$projectTreeLoading.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pull the authoritative project tree (overview structure + counts + preview
|
||||
// sessions + the scoped-session-id set). Best-effort: a failure leaves the
|
||||
// cached tree intact so the sidebar doesn't flicker.
|
||||
export async function refreshProjectTree(): Promise<void> {
|
||||
try {
|
||||
const { gateway } = await activeProjectsContext()
|
||||
await refreshProjectTreeOn(gateway)
|
||||
} catch {
|
||||
// Backend may not be ready; keep the last known tree.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -340,30 +378,117 @@ export async function fetchProjectSessions(projectId: string): Promise<SidebarPr
|
|||
}
|
||||
}
|
||||
|
||||
// One filesystem scan per app run: the heavy disk walk happens once, the result
|
||||
// is cached in the backend, and later opens read the cache. Desktop-only (needs
|
||||
// the native crawler); elsewhere discovery falls back to session-derived repos.
|
||||
let didScanRepos = false
|
||||
export interface RepoDiscoveryPolicy {
|
||||
enabled: boolean
|
||||
roots: string[]
|
||||
exclude_paths: string[]
|
||||
}
|
||||
|
||||
export function repoDiscoveryPolicyFromConfig(config: unknown): RepoDiscoveryPolicy {
|
||||
const desktopValue = config && typeof config === 'object' ? (config as { desktop?: unknown }).desktop : undefined
|
||||
const desktop =
|
||||
desktopValue && typeof desktopValue === 'object'
|
||||
? (desktopValue as {
|
||||
repo_scan_enabled?: unknown
|
||||
repo_scan_exclude_paths?: unknown
|
||||
repo_scan_roots?: unknown
|
||||
})
|
||||
: {}
|
||||
return {
|
||||
enabled: desktop.repo_scan_enabled !== false,
|
||||
roots: Array.isArray(desktop.repo_scan_roots)
|
||||
? desktop.repo_scan_roots.filter((value): value is string => typeof value === 'string')
|
||||
: [],
|
||||
exclude_paths: Array.isArray(desktop.repo_scan_exclude_paths)
|
||||
? desktop.repo_scan_exclude_paths.filter((value): value is string => typeof value === 'string')
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
export function repoDiscoveryPolicySignature(policy: RepoDiscoveryPolicy): string {
|
||||
return JSON.stringify(policy)
|
||||
}
|
||||
|
||||
interface RepoScanState {
|
||||
completedSignature?: string
|
||||
generation: number
|
||||
runningSignature?: string
|
||||
}
|
||||
|
||||
const repoScanStates = new WeakMap<HermesGateway, RepoScanState>()
|
||||
const scanningGatewayGenerations = new WeakMap<HermesGateway, number>()
|
||||
|
||||
function syncReposScanning(): void {
|
||||
const gateway = activeGateway()
|
||||
$reposScanning.set(Boolean(gateway && scanningGatewayGenerations.has(gateway)))
|
||||
}
|
||||
|
||||
$gateway.subscribe(syncReposScanning)
|
||||
|
||||
export async function scanAndRecordRepos(force = false): Promise<void> {
|
||||
const scan = desktopGit()?.scanRepos
|
||||
|
||||
if (!scan || (didScanRepos && !force)) {
|
||||
if (isDesktopFsRemoteMode()) {
|
||||
return
|
||||
}
|
||||
|
||||
didScanRepos = true
|
||||
$reposScanning.set(true)
|
||||
let context: ActiveProjectsContext
|
||||
try {
|
||||
context = await activeProjectsContext()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const scan = desktopGit()?.scanRepos
|
||||
if (!scan) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = repoScanStates.get(context.gateway) ?? { generation: 0 }
|
||||
repoScanStates.set(context.gateway, state)
|
||||
let generation: number | undefined
|
||||
|
||||
try {
|
||||
const repos = await scan([])
|
||||
await gatewayRequest('projects.record_repos', { repos })
|
||||
// The disk scan may surface new zero-session repos; refold them into the tree.
|
||||
await refreshProjectTree()
|
||||
const policy = repoDiscoveryPolicyFromConfig(await getHermesConfig(context.profile))
|
||||
const signature = repoDiscoveryPolicySignature(policy)
|
||||
if (!force && (state.completedSignature === signature || state.runningSignature === signature)) {
|
||||
return
|
||||
}
|
||||
|
||||
generation = ++state.generation
|
||||
state.runningSignature = signature
|
||||
if (!policy.enabled) {
|
||||
await gatewayRequestOn(context.gateway, 'projects.record_repos', {
|
||||
discovery_policy: policy,
|
||||
repos: []
|
||||
})
|
||||
} else {
|
||||
scanningGatewayGenerations.set(context.gateway, generation)
|
||||
syncReposScanning()
|
||||
const repos = await scan(policy.roots, {
|
||||
enabled: true,
|
||||
excludePaths: policy.exclude_paths
|
||||
})
|
||||
if (state.generation !== generation) {
|
||||
return
|
||||
}
|
||||
await gatewayRequestOn(context.gateway, 'projects.record_repos', {
|
||||
discovery_policy: policy,
|
||||
repos
|
||||
})
|
||||
}
|
||||
|
||||
if (state.generation !== generation) {
|
||||
return
|
||||
}
|
||||
state.completedSignature = signature
|
||||
await refreshProjectTreeOn(context.gateway)
|
||||
} catch {
|
||||
didScanRepos = false // let a later open retry a failed scan
|
||||
state.completedSignature = undefined
|
||||
} finally {
|
||||
$reposScanning.set(false)
|
||||
state.runningSignature = undefined
|
||||
if (scanningGatewayGenerations.get(context.gateway) === generation) {
|
||||
scanningGatewayGenerations.delete(context.gateway)
|
||||
}
|
||||
syncReposScanning()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -258,6 +258,11 @@ export interface HermesConfig {
|
|||
skin?: string
|
||||
interim_assistant_messages?: boolean
|
||||
}
|
||||
desktop?: {
|
||||
repo_scan_enabled?: boolean
|
||||
repo_scan_roots?: string[]
|
||||
repo_scan_exclude_paths?: string[]
|
||||
}
|
||||
terminal?: {
|
||||
cwd?: string
|
||||
}
|
||||
|
|
|
|||
2
contributors/emails/rudimar@outlook.com
Normal file
2
contributors/emails/rudimar@outlook.com
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
rudironsoni
|
||||
# PR #67630 author attribution
|
||||
|
|
@ -3455,6 +3455,11 @@ DEFAULT_CONFIG = {
|
|||
# Hermes Desktop (Electron app) launch options. These only affect
|
||||
# `hermes desktop`; they do not touch the CLI/gateway.
|
||||
"desktop": {
|
||||
# Git repository discovery for the Desktop Projects sidebar. Empty
|
||||
# roots preserve the historical bounded scan of the user's home.
|
||||
"repo_scan_enabled": True,
|
||||
"repo_scan_roots": [],
|
||||
"repo_scan_exclude_paths": [],
|
||||
# Extra Electron command-line flags appended to every desktop launch,
|
||||
# e.g. ["--ozone-platform=x11"] on headless/VM X11 hosts that need an
|
||||
# explicit ozone backend, or GPU workaround flags. A list of strings;
|
||||
|
|
|
|||
|
|
@ -596,6 +596,7 @@ def delete_project(conn: sqlite3.Connection, project_id: str) -> bool:
|
|||
|
||||
|
||||
_ACTIVE_META_KEY = "active_id"
|
||||
_DISCOVERY_POLICY_META_KEY = "repo_discovery_policy"
|
||||
|
||||
|
||||
def set_active(conn: sqlite3.Connection, project_id: Optional[str]) -> None:
|
||||
|
|
@ -618,6 +619,53 @@ def get_active_id(conn: sqlite3.Connection) -> Optional[str]:
|
|||
return row["value"] if row else None
|
||||
|
||||
|
||||
def get_discovery_policy_key(conn: sqlite3.Connection) -> Optional[str]:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM project_meta WHERE key = ?", (_DISCOVERY_POLICY_META_KEY,)
|
||||
).fetchone()
|
||||
return row["value"] if row else None
|
||||
|
||||
|
||||
def reconcile_discovered_repos_policy(
|
||||
conn: sqlite3.Connection,
|
||||
policy_key: str,
|
||||
*,
|
||||
preserve_unversioned: bool = False,
|
||||
) -> bool:
|
||||
"""Clear cached scan rows when their discovery policy changes.
|
||||
|
||||
Existing pre-policy rows are retained only for the backward-compatible
|
||||
default policy. Returns whether rows were cleared.
|
||||
"""
|
||||
current = get_discovery_policy_key(conn)
|
||||
if current == policy_key:
|
||||
return False
|
||||
|
||||
cleared = current is not None or not preserve_unversioned
|
||||
with write_txn(conn):
|
||||
if cleared:
|
||||
conn.execute("DELETE FROM discovered_repos")
|
||||
conn.execute(
|
||||
"INSERT INTO project_meta (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(_DISCOVERY_POLICY_META_KEY, policy_key),
|
||||
)
|
||||
return cleared
|
||||
|
||||
|
||||
def clear_discovered_repos(
|
||||
conn: sqlite3.Connection, *, policy_key: Optional[str] = None
|
||||
) -> None:
|
||||
with write_txn(conn):
|
||||
conn.execute("DELETE FROM discovered_repos")
|
||||
if policy_key is not None:
|
||||
conn.execute(
|
||||
"INSERT INTO project_meta (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(_DISCOVERY_POLICY_META_KEY, policy_key),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovered repos (filesystem scan cache)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -628,6 +676,7 @@ def record_discovered_repos(
|
|||
repos: Iterable[tuple[str, Optional[str]]],
|
||||
*,
|
||||
replace: bool = False,
|
||||
policy_key: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Persist scanned git repo roots into the cache.
|
||||
|
||||
|
|
@ -656,6 +705,12 @@ def record_discovered_repos(
|
|||
"last_seen = excluded.last_seen",
|
||||
rows,
|
||||
)
|
||||
if policy_key is not None:
|
||||
conn.execute(
|
||||
"INSERT INTO project_meta (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(_DISCOVERY_POLICY_META_KEY, policy_key),
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
|
|
|
|||
16
tests/hermes_cli/test_desktop_repo_discovery_config.py
Normal file
16
tests/hermes_cli/test_desktop_repo_discovery_config.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||
|
||||
|
||||
def test_desktop_repo_discovery_defaults_preserve_existing_behavior():
|
||||
desktop = DEFAULT_CONFIG["desktop"]
|
||||
|
||||
assert desktop["repo_scan_enabled"] is True
|
||||
assert desktop["repo_scan_roots"] == []
|
||||
assert desktop["repo_scan_exclude_paths"] == []
|
||||
|
||||
|
||||
def test_desktop_repo_discovery_keys_are_in_generated_schema():
|
||||
assert CONFIG_SCHEMA["desktop.repo_scan_enabled"]["type"] == "boolean"
|
||||
assert CONFIG_SCHEMA["desktop.repo_scan_roots"]["type"] == "list"
|
||||
assert CONFIG_SCHEMA["desktop.repo_scan_exclude_paths"]["type"] == "list"
|
||||
|
|
@ -45,6 +45,42 @@ def test_record_discovered_repos_replace_drops_stale_rows(conn):
|
|||
assert rows == {"/www/alpha": "fresh"}
|
||||
|
||||
|
||||
def test_discovery_policy_change_clears_only_discovered_rows(conn):
|
||||
project_id = pdb.create_project(conn, name="Explicit", folders=["/www/explicit"])
|
||||
pdb.record_discovered_repos(
|
||||
conn, [("/www/scanned", "scanned")], policy_key="policy-a"
|
||||
)
|
||||
|
||||
assert pdb.reconcile_discovered_repos_policy(conn, "policy-b") is True
|
||||
assert pdb.list_discovered_repos(conn) == []
|
||||
assert pdb.get_project(conn, project_id) is not None
|
||||
assert pdb.get_discovery_policy_key(conn) == "policy-b"
|
||||
|
||||
|
||||
def test_default_policy_adopts_unversioned_cache_without_clearing(conn):
|
||||
pdb.record_discovered_repos(conn, [("/www/scanned", "scanned")])
|
||||
|
||||
assert (
|
||||
pdb.reconcile_discovered_repos_policy(
|
||||
conn, "default-policy", preserve_unversioned=True
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert [row["root"] for row in pdb.list_discovered_repos(conn)] == [
|
||||
"/www/scanned"
|
||||
]
|
||||
assert pdb.get_discovery_policy_key(conn) == "default-policy"
|
||||
|
||||
|
||||
def test_clear_discovered_repos_records_policy_atomically(conn):
|
||||
pdb.record_discovered_repos(conn, [("/www/scanned", "scanned")])
|
||||
|
||||
pdb.clear_discovered_repos(conn, policy_key="disabled")
|
||||
|
||||
assert pdb.list_discovered_repos(conn) == []
|
||||
assert pdb.get_discovery_policy_key(conn) == "disabled"
|
||||
|
||||
|
||||
def test_create_get_list(conn):
|
||||
pid = pdb.create_project(conn, name="Hermes Agent", folders=["/tmp/hermes"])
|
||||
proj = pdb.get_project(conn, pid)
|
||||
|
|
@ -160,9 +196,14 @@ def test_per_profile_isolation(tmp_path):
|
|||
b = pdb.connect(db_path=tmp_path / "b" / "projects.db")
|
||||
try:
|
||||
pdb.create_project(a, name="Only In A", folders=["/a"])
|
||||
pdb.record_discovered_repos(a, [("/a/scanned", "scanned")])
|
||||
|
||||
assert [p.slug for p in pdb.list_projects(a)] == ["only-in-a"]
|
||||
assert pdb.list_projects(b) == []
|
||||
assert [row["root"] for row in pdb.list_discovered_repos(a)] == [
|
||||
"/a/scanned"
|
||||
]
|
||||
assert pdb.list_discovered_repos(b) == []
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
|
|
|||
|
|
@ -263,6 +263,84 @@ def test_record_repos_persists_and_shows_zero_session_repo(tmp_path):
|
|||
assert by_label["fresh-repo"]["sessions"] == 0
|
||||
|
||||
|
||||
def test_disabled_discovery_clears_cache_and_rejects_new_scan(monkeypatch, tmp_path):
|
||||
repo = tmp_path / "cached-repo"
|
||||
repo.mkdir()
|
||||
session_repo = tmp_path / "session-repo"
|
||||
session_repo.mkdir()
|
||||
subprocess.run(
|
||||
["git", "init"], cwd=session_repo, check=True, capture_output=True
|
||||
)
|
||||
server._get_db().create_session("session-repo", "cli", cwd=str(session_repo))
|
||||
_call("projects.record_repos", {"repos": [{"root": str(repo)}]})
|
||||
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_load_cfg",
|
||||
lambda: {
|
||||
"desktop": {
|
||||
"repo_scan_enabled": False,
|
||||
"repo_scan_roots": [],
|
||||
"repo_scan_exclude_paths": [],
|
||||
}
|
||||
},
|
||||
)
|
||||
result = _call(
|
||||
"projects.record_repos",
|
||||
{
|
||||
"repos": [{"root": str(repo)}],
|
||||
"discovery_policy": {
|
||||
"enabled": False,
|
||||
"roots": [],
|
||||
"exclude_paths": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result["accepted"] is False
|
||||
assert all(item["root"] != str(repo) for item in result["repos"])
|
||||
assert any(item["root"] == str(session_repo) for item in result["repos"])
|
||||
|
||||
|
||||
def test_nondefault_policy_rejects_stale_or_legacy_results(monkeypatch, tmp_path):
|
||||
root = tmp_path / "allowed"
|
||||
root.mkdir()
|
||||
policy = {
|
||||
"enabled": True,
|
||||
"roots": [str(root)],
|
||||
"exclude_paths": [],
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_load_cfg",
|
||||
lambda: {
|
||||
"desktop": {
|
||||
"repo_scan_enabled": True,
|
||||
"repo_scan_roots": [str(root)],
|
||||
"repo_scan_exclude_paths": [],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
legacy = _call("projects.record_repos", {"repos": [{"root": str(root)}]})
|
||||
stale = _call(
|
||||
"projects.record_repos",
|
||||
{
|
||||
"repos": [{"root": str(root)}],
|
||||
"discovery_policy": {**policy, "roots": [str(tmp_path / "other")]},
|
||||
},
|
||||
)
|
||||
accepted = _call(
|
||||
"projects.record_repos",
|
||||
{"repos": [{"root": str(root)}], "discovery_policy": policy},
|
||||
)
|
||||
|
||||
assert legacy["accepted"] is False
|
||||
assert stale["accepted"] is False
|
||||
assert accepted["accepted"] is True
|
||||
assert any(item["root"] == str(root) for item in accepted["repos"])
|
||||
|
||||
|
||||
def test_discover_repos_from_full_history(tmp_path):
|
||||
repo = tmp_path / "myrepo"
|
||||
(repo / "src").mkdir(parents=True)
|
||||
|
|
|
|||
|
|
@ -12085,7 +12085,67 @@ def _is_session_cwd_junk(cwd: str) -> bool:
|
|||
return real == home or real == hermes_home
|
||||
|
||||
|
||||
def _discover_repos_payload(db, *, conn=None, backfill: bool = True) -> list[dict]:
|
||||
def _repo_discovery_policy(raw: dict | None = None) -> dict:
|
||||
"""Return the effective, profile-local Desktop repository scan policy."""
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
defaults = DEFAULT_CONFIG["desktop"]
|
||||
source = raw if isinstance(raw, dict) else (_load_cfg().get("desktop") or {})
|
||||
if not isinstance(source, dict):
|
||||
source = {}
|
||||
|
||||
enabled = source.get("enabled", source.get("repo_scan_enabled", defaults["repo_scan_enabled"]))
|
||||
roots = source.get("roots", source.get("repo_scan_roots", defaults["repo_scan_roots"]))
|
||||
excludes = source.get(
|
||||
"exclude_paths",
|
||||
source.get("repo_scan_exclude_paths", defaults["repo_scan_exclude_paths"]),
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled": enabled if isinstance(enabled, bool) else defaults["repo_scan_enabled"],
|
||||
"roots": [value.strip() for value in roots if isinstance(value, str) and value.strip()]
|
||||
if isinstance(roots, list)
|
||||
else list(defaults["repo_scan_roots"]),
|
||||
"exclude_paths": [
|
||||
value.strip()
|
||||
for value in excludes
|
||||
if isinstance(value, str) and value.strip()
|
||||
]
|
||||
if isinstance(excludes, list)
|
||||
else list(defaults["repo_scan_exclude_paths"]),
|
||||
}
|
||||
|
||||
|
||||
def _repo_discovery_policy_key(policy: dict) -> str:
|
||||
def _paths(values: list[str]) -> list[str]:
|
||||
normalized = set()
|
||||
home = os.path.expanduser("~")
|
||||
for value in values:
|
||||
expanded = os.path.expanduser(value)
|
||||
if not os.path.isabs(expanded):
|
||||
expanded = os.path.join(home, expanded)
|
||||
normalized.add(os.path.normcase(os.path.abspath(expanded)))
|
||||
return sorted(normalized)
|
||||
|
||||
canonical = {
|
||||
"enabled": bool(policy["enabled"]),
|
||||
"roots": _paths(policy["roots"]),
|
||||
"exclude_paths": _paths(policy["exclude_paths"]),
|
||||
}
|
||||
return json.dumps(canonical, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _repo_discovery_policy_is_default(policy: dict) -> bool:
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
return _repo_discovery_policy_key(policy) == _repo_discovery_policy_key(
|
||||
_repo_discovery_policy(DEFAULT_CONFIG["desktop"])
|
||||
)
|
||||
|
||||
|
||||
def _discover_repos_payload(
|
||||
db, *, conn=None, backfill: bool = True, include_cached: bool = True
|
||||
) -> list[dict]:
|
||||
"""Merge filesystem-scanned repos (cached) with session-derived repo roots.
|
||||
|
||||
Repo-first: the disk scan (persisted by `projects.record_repos`) surfaces
|
||||
|
|
@ -12129,6 +12189,16 @@ def _discover_repos_payload(db, *, conn=None, backfill: bool = True) -> list[dic
|
|||
except Exception:
|
||||
logger.debug("failed to backfill repo roots", exc_info=True)
|
||||
|
||||
if not include_cached:
|
||||
out = sorted(repos.values(), key=lambda repo: repo["last_active"], reverse=True)
|
||||
for repo in out:
|
||||
repo["label"] = (
|
||||
repo["label"]
|
||||
or os.path.basename(repo["root"].rstrip("/\\"))
|
||||
or repo["root"]
|
||||
)
|
||||
return out
|
||||
|
||||
# Filesystem-scanned roots from the cache (may have zero sessions). Reuse the
|
||||
# caller's projects.db connection when given, else open a short-lived one.
|
||||
try:
|
||||
|
|
@ -12165,7 +12235,20 @@ def _(rid, params: dict) -> dict:
|
|||
db = _get_db()
|
||||
if db is None:
|
||||
return _ok(rid, {"repos": []})
|
||||
return _ok(rid, {"repos": _discover_repos_payload(db)})
|
||||
from hermes_cli import projects_db as pdb
|
||||
|
||||
policy = _repo_discovery_policy()
|
||||
policy_key = _repo_discovery_policy_key(policy)
|
||||
with pdb.connect_closing() as conn:
|
||||
pdb.reconcile_discovered_repos_policy(
|
||||
conn,
|
||||
policy_key,
|
||||
preserve_unversioned=_repo_discovery_policy_is_default(policy),
|
||||
)
|
||||
repos = _discover_repos_payload(
|
||||
db, conn=conn, include_cached=policy["enabled"]
|
||||
)
|
||||
return _ok(rid, {"repos": repos, "discovery_policy": policy})
|
||||
except Exception as e:
|
||||
return _err(rid, 5061, str(e))
|
||||
|
||||
|
|
@ -12178,6 +12261,22 @@ def _(rid, params: dict) -> dict:
|
|||
try:
|
||||
from hermes_cli import projects_db as pdb
|
||||
|
||||
policy = _repo_discovery_policy()
|
||||
policy_key = _repo_discovery_policy_key(policy)
|
||||
incoming_raw = params.get("discovery_policy")
|
||||
incoming_policy = (
|
||||
_repo_discovery_policy(incoming_raw)
|
||||
if isinstance(incoming_raw, dict)
|
||||
else None
|
||||
)
|
||||
incoming_matches = (
|
||||
incoming_policy is not None
|
||||
and _repo_discovery_policy_key(incoming_policy) == policy_key
|
||||
)
|
||||
accept_legacy_default = (
|
||||
incoming_policy is None and _repo_discovery_policy_is_default(policy)
|
||||
)
|
||||
|
||||
pairs: list[tuple[str, str | None]] = []
|
||||
for item in params.get("repos") or []:
|
||||
if isinstance(item, str):
|
||||
|
|
@ -12186,10 +12285,34 @@ def _(rid, params: dict) -> dict:
|
|||
pairs.append((str(item["root"]), item.get("label")))
|
||||
|
||||
with pdb.connect_closing() as conn:
|
||||
pdb.record_discovered_repos(conn, pairs, replace=True)
|
||||
pdb.reconcile_discovered_repos_policy(
|
||||
conn,
|
||||
policy_key,
|
||||
preserve_unversioned=_repo_discovery_policy_is_default(policy),
|
||||
)
|
||||
accepted = bool(
|
||||
policy["enabled"] and (incoming_matches or accept_legacy_default)
|
||||
)
|
||||
if accepted:
|
||||
pdb.record_discovered_repos(
|
||||
conn, pairs, replace=True, policy_key=policy_key
|
||||
)
|
||||
elif not policy["enabled"]:
|
||||
pdb.clear_discovered_repos(conn, policy_key=policy_key)
|
||||
|
||||
db = _get_db()
|
||||
return _ok(rid, {"repos": _discover_repos_payload(db) if db is not None else []})
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"repos": _discover_repos_payload(
|
||||
db, include_cached=policy["enabled"]
|
||||
)
|
||||
if db is not None
|
||||
else [],
|
||||
"accepted": accepted,
|
||||
"discovery_policy": policy,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5061, str(e))
|
||||
|
||||
|
|
@ -12260,11 +12383,28 @@ def _project_tree_inputs(
|
|||
|
||||
from hermes_cli import projects_db as pdb
|
||||
|
||||
policy = _repo_discovery_policy()
|
||||
policy_key = _repo_discovery_policy_key(policy)
|
||||
with pdb.connect_closing() as conn:
|
||||
if include_discovered:
|
||||
pdb.reconcile_discovered_repos_policy(
|
||||
conn,
|
||||
policy_key,
|
||||
preserve_unversioned=_repo_discovery_policy_is_default(policy),
|
||||
)
|
||||
projects = [p.to_dict() for p in pdb.list_projects(conn)]
|
||||
active_id = pdb.get_active_id(conn)
|
||||
# backfill stays off the hot tree path — grouping uses the live resolver.
|
||||
discovered = _discover_repos_payload(db, conn=conn, backfill=False) if include_discovered else []
|
||||
discovered = (
|
||||
_discover_repos_payload(
|
||||
db,
|
||||
conn=conn,
|
||||
backfill=False,
|
||||
include_cached=policy["enabled"],
|
||||
)
|
||||
if include_discovered
|
||||
else []
|
||||
)
|
||||
|
||||
return sessions, projects, discovered, active_id
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,23 @@ The bar along the bottom of the chat shows live session state and exposes quick
|
|||
|
||||
Chatting against a Hermes instance on another machine instead of the bundled local backend? See [Connecting to a remote backend](#connecting-to-a-remote-backend) below — and for the full picture of how the remote-hosted dashboard connection works (the auth gate, the `/api/ws` chat socket, and WebSocket close-code triage), see [Web Dashboard → Connecting Hermes Desktop to a remote backend](./features/web-dashboard.md#connecting-hermes-desktop-to-a-remote-backend).
|
||||
|
||||
#### Repository discovery
|
||||
|
||||
Hermes Desktop discovers local Git repositories for the Projects sidebar by scanning your home directory to a bounded depth. You can change this per profile in **Settings → Workspace**, or in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
desktop:
|
||||
repo_scan_enabled: true
|
||||
repo_scan_roots: []
|
||||
repo_scan_exclude_paths: []
|
||||
```
|
||||
|
||||
- Set `repo_scan_enabled: false` to stop the filesystem scan completely. Existing disk-discovery cache rows for that profile are cleared; explicit projects and repositories inferred from intentional Hermes sessions remain available.
|
||||
- Set `repo_scan_roots` to a list of folders to restrict scanning. An empty list preserves the default home-directory scan.
|
||||
- Set `repo_scan_exclude_paths` to folders whose complete subtrees should be skipped.
|
||||
|
||||
Changing any of these values invalidates only that profile's disk-discovery cache and starts a policy-compliant refresh. **Hide from sidebar** remains a separate per-item curation action.
|
||||
|
||||
#### Choosing a model
|
||||
|
||||
The model picker lives in the **composer**, just left of the microphone. Click it to switch the model, reasoning effort, and fast mode from one dropdown.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue