Merge branch 'main' into one-folder
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
GitFork,
|
||||
ListPlus,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Command,
|
||||
Sparkles,
|
||||
@@ -543,19 +544,58 @@ export function MessageInput({
|
||||
() => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)),
|
||||
[availableCommands, expertIdSet]
|
||||
)
|
||||
const [slashDropdownOpen, setSlashDropdownOpen] = useState(false)
|
||||
const [slashDropdownSearch, setSlashDropdownSearch] = useState("")
|
||||
const slashDropdownInputRef = useRef<HTMLInputElement>(null)
|
||||
const filteredSlashDropdownCommands = useMemo(() => {
|
||||
const q = slashDropdownSearch.toLowerCase().trim()
|
||||
if (!q) return slashCommands
|
||||
const nameMatches: typeof slashCommands = []
|
||||
const descOnlyMatches: typeof slashCommands = []
|
||||
for (const cmd of slashCommands) {
|
||||
if (cmd.name.toLowerCase().includes(q)) {
|
||||
nameMatches.push(cmd)
|
||||
} else if (cmd.description?.toLowerCase().includes(q)) {
|
||||
descOnlyMatches.push(cmd)
|
||||
}
|
||||
}
|
||||
return [...nameMatches, ...descOnlyMatches]
|
||||
}, [slashCommands, slashDropdownSearch])
|
||||
const handleSlashDropdownOpenChange = useCallback((open: boolean) => {
|
||||
setSlashDropdownOpen(open)
|
||||
if (!open) setSlashDropdownSearch("")
|
||||
}, [])
|
||||
// Radix composes this handler with its own content-focus via
|
||||
// composeEventHandlers (default `checkForDefaultPrevented: true`), so
|
||||
// calling preventDefault here skips radix's autofocus entirely. The prop
|
||||
// is accepted at runtime but omitted from DropdownMenuContent's public
|
||||
// TypeScript surface, so it has to be passed through an untyped spread.
|
||||
const slashDropdownFocusProps = useMemo(
|
||||
() => ({
|
||||
onOpenAutoFocus: (event: Event) => {
|
||||
event.preventDefault()
|
||||
slashDropdownInputRef.current?.focus()
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const slashFilterText = useMemo(() => {
|
||||
if (!slashMenuOpen || slashTriggerPos == null) return ""
|
||||
const trigger = text[slashTriggerPos]
|
||||
if (trigger !== "/" && trigger !== "$") return ""
|
||||
const afterTrigger = text.slice(slashTriggerPos + 1)
|
||||
const endIdx = afterTrigger.search(/\s/)
|
||||
return endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx)
|
||||
}, [slashMenuOpen, text, slashTriggerPos])
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
if (!slashMenuOpen || slashCommands.length === 0 || slashTriggerPos == null)
|
||||
return []
|
||||
if (text[slashTriggerPos] !== "/") return []
|
||||
const afterTrigger = text.slice(slashTriggerPos + 1)
|
||||
const endIdx = afterTrigger.search(/\s/)
|
||||
const filter = (
|
||||
endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx)
|
||||
).toLowerCase()
|
||||
const filter = slashFilterText.toLowerCase()
|
||||
return slashCommands.filter((cmd) =>
|
||||
cmd.name.toLowerCase().startsWith(filter)
|
||||
cmd.name.toLowerCase().includes(filter)
|
||||
)
|
||||
}, [slashMenuOpen, slashCommands, text, slashTriggerPos])
|
||||
}, [slashMenuOpen, slashCommands, text, slashTriggerPos, slashFilterText])
|
||||
const filteredSlashSkills = useMemo(() => {
|
||||
// Skills autocomplete is Codex-only and triggered by `$`.
|
||||
if (agentType !== "codex") return []
|
||||
@@ -566,15 +606,26 @@ export function MessageInput({
|
||||
)
|
||||
return []
|
||||
if (text[slashTriggerPos] !== "$") return []
|
||||
const afterTrigger = text.slice(slashTriggerPos + 1)
|
||||
const endIdx = afterTrigger.search(/\s/)
|
||||
const filter = (
|
||||
endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx)
|
||||
).toLowerCase()
|
||||
return nonExpertSkills.filter((skill) =>
|
||||
skill.id.toLowerCase().startsWith(filter)
|
||||
)
|
||||
}, [slashMenuOpen, nonExpertSkills, text, agentType, slashTriggerPos])
|
||||
const filter = slashFilterText.toLowerCase()
|
||||
if (!filter) return nonExpertSkills
|
||||
const nameMatches: typeof nonExpertSkills = []
|
||||
const idOnlyMatches: typeof nonExpertSkills = []
|
||||
for (const skill of nonExpertSkills) {
|
||||
if (skill.name.toLowerCase().includes(filter)) {
|
||||
nameMatches.push(skill)
|
||||
} else if (skill.id.toLowerCase().includes(filter)) {
|
||||
idOnlyMatches.push(skill)
|
||||
}
|
||||
}
|
||||
return [...nameMatches, ...idOnlyMatches]
|
||||
}, [
|
||||
slashMenuOpen,
|
||||
nonExpertSkills,
|
||||
text,
|
||||
agentType,
|
||||
slashTriggerPos,
|
||||
slashFilterText,
|
||||
])
|
||||
const slashAutocompleteCount =
|
||||
filteredSlashCommands.length + filteredSlashSkills.length
|
||||
|
||||
@@ -950,6 +1001,25 @@ export function MessageInput({
|
||||
[onModeChange]
|
||||
)
|
||||
|
||||
const handleSlashSearchChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const pos = slashTriggerPosRef.current
|
||||
const current = textRef.current
|
||||
if (pos == null || pos < 0 || pos >= current.length) return
|
||||
const trigger = current[pos]
|
||||
if (trigger !== "/" && trigger !== "$") return
|
||||
const afterTrigger = current.slice(pos + 1)
|
||||
const endIdx = afterTrigger.search(/\s/)
|
||||
const tokenEnd = endIdx === -1 ? current.length : pos + 1 + endIdx
|
||||
const before = current.slice(0, pos + 1)
|
||||
const rest = current.slice(tokenEnd)
|
||||
const sanitized = e.target.value.replace(/\s+/g, "")
|
||||
setText(before + sanitized + rest)
|
||||
setSlashSelectedIndex(0)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleSlashSelect = useCallback((cmd: AvailableCommandInfo) => {
|
||||
const pos = slashTriggerPosRef.current
|
||||
const current = textRef.current
|
||||
@@ -1046,6 +1116,49 @@ export function MessageInput({
|
||||
[expertPrefix]
|
||||
)
|
||||
|
||||
const handleSlashSearchKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const total = filteredSlashCommands.length + filteredSlashSkills.length
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
if (total === 0) return
|
||||
setSlashSelectedIndex((i) => (i < total - 1 ? i + 1 : 0))
|
||||
return
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
if (total === 0) return
|
||||
setSlashSelectedIndex((i) => (i > 0 ? i - 1 : total - 1))
|
||||
return
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
if (total === 0) return
|
||||
e.preventDefault()
|
||||
if (slashSelectedIndex < filteredSlashCommands.length) {
|
||||
handleSlashSelect(filteredSlashCommands[slashSelectedIndex])
|
||||
} else {
|
||||
const skillIndex = slashSelectedIndex - filteredSlashCommands.length
|
||||
const skill = filteredSlashSkills[skillIndex]
|
||||
if (skill) handleSkillAutocompleteSelect(skill)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setSlashMenuOpen(false)
|
||||
setSlashTriggerPos(null)
|
||||
requestAnimationFrame(() => textareaRef.current?.focus())
|
||||
}
|
||||
},
|
||||
[
|
||||
filteredSlashCommands,
|
||||
filteredSlashSkills,
|
||||
slashSelectedIndex,
|
||||
handleSlashSelect,
|
||||
handleSkillAutocompleteSelect,
|
||||
]
|
||||
)
|
||||
|
||||
// Experts always inject `prefix + expert-id ` at the very front of the
|
||||
// input, never at the cursor. The expert skill is a whole-turn directive
|
||||
// that the agent inspects first, so prepending keeps semantics unambiguous
|
||||
@@ -1719,63 +1832,77 @@ export function MessageInput({
|
||||
onDrop={handleContainerDrop}
|
||||
>
|
||||
{slashMenuOpen && slashAutocompleteCount > 0 && (
|
||||
<div
|
||||
ref={slashMenuListRef}
|
||||
className="absolute bottom-full left-0 right-0 mb-1 z-50 max-h-[min(16rem,40dvh)] overflow-y-auto rounded-xl border border-border bg-popover p-1 shadow-lg"
|
||||
>
|
||||
{filteredSlashCommands.map((cmd, i) => (
|
||||
<button
|
||||
key={`cmd-${cmd.name}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm",
|
||||
i === slashSelectedIndex
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleSlashSelect(cmd)
|
||||
}}
|
||||
>
|
||||
<span className="shrink-0 font-mono text-primary">
|
||||
/{cmd.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{cmd.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredSlashSkills.map((skill, i) => {
|
||||
const absoluteIndex = filteredSlashCommands.length + i
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-1 z-50 flex max-h-[min(16rem,40dvh)] flex-col overflow-hidden rounded-xl border border-border bg-popover shadow-lg">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
role="searchbox"
|
||||
aria-label={t("slashSearchPlaceholder")}
|
||||
value={slashFilterText}
|
||||
onChange={handleSlashSearchChange}
|
||||
onKeyDown={handleSlashSearchKeyDown}
|
||||
placeholder={t("slashSearchPlaceholder")}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div ref={slashMenuListRef} className="flex-1 overflow-y-auto p-1">
|
||||
{filteredSlashCommands.map((cmd, i) => (
|
||||
<button
|
||||
key={`skill-${skill.scope}-${skill.id}`}
|
||||
key={`cmd-${cmd.name}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm",
|
||||
absoluteIndex === slashSelectedIndex
|
||||
i === slashSelectedIndex
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleSkillAutocompleteSelect(skill)
|
||||
handleSlashSelect(cmd)
|
||||
}}
|
||||
>
|
||||
<BookOpenText className="mt-0.5 size-4 shrink-0 text-primary/80" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{skill.name}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||
{expertPrefix}
|
||||
{skill.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 font-mono text-primary">
|
||||
/{cmd.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{cmd.description}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
{filteredSlashSkills.map((skill, i) => {
|
||||
const absoluteIndex = filteredSlashCommands.length + i
|
||||
return (
|
||||
<button
|
||||
key={`skill-${skill.scope}-${skill.id}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm",
|
||||
absoluteIndex === slashSelectedIndex
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleSkillAutocompleteSelect(skill)
|
||||
}}
|
||||
>
|
||||
<BookOpenText className="mt-0.5 size-4 shrink-0 text-primary/80" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{skill.name}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
||||
{expertPrefix}
|
||||
{skill.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{atMenuOpen && filteredAtFiles.length > 0 && (
|
||||
@@ -1946,7 +2073,10 @@ export function MessageInput({
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
open={slashDropdownOpen}
|
||||
onOpenChange={handleSlashDropdownOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={disabled || slashCommands.length === 0}
|
||||
@@ -1965,23 +2095,82 @@ export function MessageInput({
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="min-w-72 overflow-y-auto"
|
||||
className="flex min-w-72 flex-col overflow-hidden p-0"
|
||||
style={{
|
||||
maxHeight:
|
||||
"min(32rem, var(--radix-dropdown-menu-content-available-height))",
|
||||
}}
|
||||
{...slashDropdownFocusProps}
|
||||
>
|
||||
{slashCommands.map((cmd) => (
|
||||
<DropdownMenuItem
|
||||
key={cmd.name}
|
||||
onClick={() => handleSlashPopoverSelect(cmd)}
|
||||
>
|
||||
<DropdownRadioItemContent
|
||||
label={`/${cmd.name}`}
|
||||
description={cmd.description}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={slashDropdownInputRef}
|
||||
type="text"
|
||||
role="searchbox"
|
||||
aria-label={t("slashSearchPlaceholder")}
|
||||
value={slashDropdownSearch}
|
||||
onChange={(e) => setSlashDropdownSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
const container = e.currentTarget.closest(
|
||||
'[data-slot="dropdown-menu-content"]'
|
||||
)
|
||||
const firstItem =
|
||||
container?.querySelector<HTMLElement>(
|
||||
'[role="menuitem"]'
|
||||
)
|
||||
firstItem?.focus()
|
||||
return
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
const first = filteredSlashDropdownCommands[0]
|
||||
if (first) {
|
||||
handleSlashPopoverSelect(first)
|
||||
setSlashDropdownOpen(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === "Escape" || e.key === "Tab") return
|
||||
// Prevent radix DropdownMenu's built-in typeahead from
|
||||
// hijacking letter keys while the user is typing.
|
||||
e.stopPropagation()
|
||||
}}
|
||||
placeholder={t("slashSearchPlaceholder")}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{filteredSlashDropdownCommands.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("slashSearchEmpty")}
|
||||
</div>
|
||||
) : (
|
||||
filteredSlashDropdownCommands.map((cmd) => (
|
||||
<DropdownMenuItem
|
||||
key={cmd.name}
|
||||
onClick={() => handleSlashPopoverSelect(cmd)}
|
||||
// Radix focuses the item on pointermove, which fires
|
||||
// while scrolling (items slide under the cursor) and
|
||||
// steals focus from the search input. Short-circuit
|
||||
// that default with preventDefault so the search
|
||||
// keeps focus until the user explicitly clicks.
|
||||
onPointerMove={(e) => e.preventDefault()}
|
||||
onPointerLeave={(e) => e.preventDefault()}
|
||||
className="hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<DropdownRadioItemContent
|
||||
label={`/${cmd.name}`}
|
||||
description={cmd.description}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* 宽屏内联显示,窄屏(<480px)通过"更多"气泡显示 */}
|
||||
|
||||
@@ -888,6 +888,11 @@ export function FileTreeTab() {
|
||||
new Map()
|
||||
)
|
||||
const lazyLoadingDirPathsRef = useRef<Set<string>>(new Set())
|
||||
const loadDirectoryChildrenRef = useRef<
|
||||
((dirPath: string) => Promise<void>) | null
|
||||
>(null)
|
||||
const expandedPathsRef = useRef<Set<string>>(new Set([FILE_TREE_ROOT_PATH]))
|
||||
const workspaceTreeRef = useRef<FileTreeNode[]>([])
|
||||
const externalConflictSignatureByPathRef = useRef<Map<string, string>>(
|
||||
new Map()
|
||||
)
|
||||
@@ -967,36 +972,58 @@ export function FileTreeTab() {
|
||||
return
|
||||
}
|
||||
|
||||
// Drop the lazy-load override cache so the fresh snapshot is not
|
||||
// masked by stale children (e.g. after deletes / renames / rollbacks
|
||||
// or files the agent just created). Reading expanded paths via a ref
|
||||
// keeps fetchTree's identity stable across expand/collapse so
|
||||
// downstream memoization is not invalidated on every tree interaction.
|
||||
const pathsToReload = Array.from(expandedPathsRef.current).filter(
|
||||
(path) => path !== FILE_TREE_ROOT_PATH
|
||||
)
|
||||
lazyLoadedChildrenByPathRef.current.clear()
|
||||
await workspaceState.requestResync("manual_refresh")
|
||||
// Re-hydrate children for directories beyond WORKSPACE_TREE_MAX_DEPTH
|
||||
// that are still expanded — the backend snapshot does not include them.
|
||||
const loader = loadDirectoryChildrenRef.current
|
||||
if (loader) {
|
||||
for (const path of pathsToReload) {
|
||||
void loader(path)
|
||||
}
|
||||
}
|
||||
},
|
||||
[folder?.path, workspaceState]
|
||||
)
|
||||
|
||||
// Tree updates are the only source that should cause a full setNodes.
|
||||
// applyLazyTreeOverrides rebuilds every directory node object, which forces
|
||||
// React to re-render the entire tree. Keeping this effect narrow avoids
|
||||
// wasted work on health / seq / error / git transitions that don't touch
|
||||
// the tree shape (e.g. the intermediate "resyncing" patch during a refresh).
|
||||
useEffect(() => {
|
||||
workspaceTreeRef.current = workspaceState.tree
|
||||
setNodes(
|
||||
applyLazyTreeOverrides(
|
||||
workspaceState.tree,
|
||||
lazyLoadedChildrenByPathRef.current
|
||||
)
|
||||
)
|
||||
}, [folder?.path, workspaceState.tree])
|
||||
|
||||
useEffect(() => {
|
||||
const nextStatusByPath = new Map<string, string>()
|
||||
for (const entry of workspaceState.git) {
|
||||
nextStatusByPath.set(entry.path, entry.status)
|
||||
}
|
||||
setGitStatusByPath(nextStatusByPath)
|
||||
setGitEnabled(true)
|
||||
}, [workspaceState.git])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(
|
||||
workspaceState.health === "resyncing" && workspaceState.seq === 0
|
||||
)
|
||||
setError(workspaceState.health === "degraded" ? workspaceState.error : null)
|
||||
}, [
|
||||
folder?.path,
|
||||
workspaceState.error,
|
||||
workspaceState.git,
|
||||
workspaceState.health,
|
||||
workspaceState.seq,
|
||||
workspaceState.tree,
|
||||
])
|
||||
}, [workspaceState.error, workspaceState.health, workspaceState.seq])
|
||||
|
||||
const loadDirectoryChildren = useCallback(
|
||||
async (dirPath: string) => {
|
||||
@@ -1007,12 +1034,19 @@ export function FileTreeTab() {
|
||||
if (lazyLoadedChildrenByPathRef.current.has(normalizedDirPath)) return
|
||||
if (lazyLoadingDirPathsRef.current.has(normalizedDirPath)) return
|
||||
|
||||
const existingChildren = findDirectoryChildren(nodes, normalizedDirPath)
|
||||
// Check the backend tree (source of truth), not the rendered `nodes`.
|
||||
// `nodes` carries stale lazy-cache overrides that don't invalidate
|
||||
// until a tree_replace delta arrives — but for directories beyond
|
||||
// WORKSPACE_TREE_MAX_DEPTH the backend never emits tree_replace for
|
||||
// changes inside them (their children are not in tree_snapshot, so
|
||||
// the refreshed tree compares equal to the old one). Checking
|
||||
// `nodes` would cause fetchTree's forced reload to short-circuit on
|
||||
// the stale override and miss deletions / creations in deep dirs.
|
||||
const existingChildren = findDirectoryChildren(
|
||||
workspaceTreeRef.current,
|
||||
normalizedDirPath
|
||||
)
|
||||
if (existingChildren && existingChildren.length > 0) {
|
||||
lazyLoadedChildrenByPathRef.current.set(
|
||||
normalizedDirPath,
|
||||
existingChildren
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1033,9 +1067,103 @@ export function FileTreeTab() {
|
||||
lazyLoadingDirPathsRef.current.delete(normalizedDirPath)
|
||||
}
|
||||
},
|
||||
[folder?.path, nodes]
|
||||
[folder?.path]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
loadDirectoryChildrenRef.current = loadDirectoryChildren
|
||||
}, [loadDirectoryChildren])
|
||||
|
||||
useEffect(() => {
|
||||
expandedPathsRef.current = expandedPaths
|
||||
}, [expandedPaths])
|
||||
|
||||
// Subscribe to workspace envelopes to invalidate lazy-loaded overrides for
|
||||
// directories beyond WORKSPACE_TREE_MAX_DEPTH. Those directories are never
|
||||
// reflected in the backend's depth-2 tree_snapshot, so changes inside them
|
||||
// don't emit a tree_replace delta — the frontend has to target invalidation
|
||||
// by matching each `changed_paths` entry against its cached ancestors.
|
||||
// The backend already debounces raw FS events (1s / 3s max), so we only
|
||||
// need a microtask hop here to merge paths that hit the same cached
|
||||
// ancestor within one envelope (or any synchronous burst of envelopes).
|
||||
const subscribeWorkspaceEnvelopes = workspaceState.subscribeEnvelopes
|
||||
useEffect(() => {
|
||||
if (!subscribeWorkspaceEnvelopes) return
|
||||
|
||||
const pendingPaths = new Set<string>()
|
||||
let flushScheduled = false
|
||||
let disposed = false
|
||||
|
||||
const flushPending = () => {
|
||||
flushScheduled = false
|
||||
if (disposed || pendingPaths.size === 0) return
|
||||
const paths = Array.from(pendingPaths)
|
||||
pendingPaths.clear()
|
||||
|
||||
const loader = loadDirectoryChildrenRef.current
|
||||
const cache = lazyLoadedChildrenByPathRef.current
|
||||
const invalidated = new Set<string>()
|
||||
|
||||
for (const changed of paths) {
|
||||
const normalized = normalizeComparePath(changed)
|
||||
if (!normalized) continue
|
||||
// When the changed path is itself a cached directory (FS events
|
||||
// that report the directory directly, e.g. a rename or a dir-level
|
||||
// notification), its own entry is stale — invalidate it.
|
||||
if (cache.has(normalized)) {
|
||||
invalidated.add(normalized)
|
||||
}
|
||||
// Independently of the above, walk up to the nearest cached
|
||||
// ancestor: the ancestor's children listing may also be stale
|
||||
// (a child was added, removed, or renamed). Without this, cases
|
||||
// where both a parent and child are cached leave the parent
|
||||
// holding a ghost reference to the old child.
|
||||
let cursor = normalized
|
||||
while (cursor.length > 0) {
|
||||
const slash = cursor.lastIndexOf("/")
|
||||
const parent = slash === -1 ? "" : cursor.slice(0, slash)
|
||||
if (parent.length === 0) break
|
||||
if (cache.has(parent)) {
|
||||
invalidated.add(parent)
|
||||
break
|
||||
}
|
||||
cursor = parent
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidated.size === 0) return
|
||||
for (const path of invalidated) {
|
||||
cache.delete(path)
|
||||
}
|
||||
if (!loader) return
|
||||
// Skip refetching directories that are no longer expanded — their
|
||||
// cleared cache will be re-hydrated on the next expansion via the
|
||||
// expandedPaths effect. This avoids spurious getFileTree traffic
|
||||
// for collapsed branches under bursty FS activity.
|
||||
const expanded = expandedPathsRef.current
|
||||
for (const path of invalidated) {
|
||||
if (!expanded.has(path)) continue
|
||||
void loader(path)
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeWorkspaceEnvelopes(({ changed_paths }) => {
|
||||
if (!changed_paths || changed_paths.length === 0) return
|
||||
for (const path of changed_paths) {
|
||||
pendingPaths.add(path)
|
||||
}
|
||||
if (flushScheduled) return
|
||||
flushScheduled = true
|
||||
queueMicrotask(flushPending)
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unsubscribe()
|
||||
pendingPaths.clear()
|
||||
}
|
||||
}, [subscribeWorkspaceEnvelopes])
|
||||
|
||||
useEffect(() => {
|
||||
const previousExpanded = previousExpandedPathsRef.current
|
||||
for (const path of expandedPaths) {
|
||||
|
||||
@@ -30,9 +30,16 @@ export interface WorkspaceStateView {
|
||||
isGitRepo: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceEnvelopeListener = (envelope: {
|
||||
seq: number
|
||||
kind: string
|
||||
changed_paths: string[]
|
||||
}) => void
|
||||
|
||||
export interface WorkspaceStateResult extends WorkspaceStateView {
|
||||
requestResync: (reason?: string) => Promise<void>
|
||||
restart: () => Promise<void>
|
||||
subscribeEnvelopes: (listener: WorkspaceEnvelopeListener) => () => void
|
||||
}
|
||||
|
||||
const WORKSPACE_PROTOCOL_VERSION = 1
|
||||
@@ -140,6 +147,7 @@ class WorkspaceStateStore {
|
||||
private readonly rootPath: string
|
||||
private readonly normalizedRootPath: string
|
||||
private listeners = new Set<() => void>()
|
||||
private envelopeListeners = new Set<WorkspaceEnvelopeListener>()
|
||||
private state: WorkspaceStateView
|
||||
private refCount = 0
|
||||
private started = false
|
||||
@@ -171,6 +179,13 @@ class WorkspaceStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
subscribeEnvelopes = (listener: WorkspaceEnvelopeListener): (() => void) => {
|
||||
this.envelopeListeners.add(listener)
|
||||
return () => {
|
||||
this.envelopeListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
acquire = () => {
|
||||
this.cancelPendingShutdown()
|
||||
this.cancelEviction()
|
||||
@@ -212,6 +227,16 @@ class WorkspaceStateStore {
|
||||
this.patchState((prev) => applySnapshot(prev, snapshot))
|
||||
if (snapshot.full) {
|
||||
this.hasBaselineSnapshot = true
|
||||
} else {
|
||||
// Forward replayed envelopes so downstream cache-invalidation
|
||||
// hooks catch up on FS activity that happened while disconnected.
|
||||
for (const envelope of snapshot.deltas) {
|
||||
this.notifyEnvelope({
|
||||
seq: envelope.seq,
|
||||
kind: envelope.kind,
|
||||
changed_paths: envelope.changed_paths ?? [],
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.patchState((prev) => ({
|
||||
@@ -315,6 +340,15 @@ class WorkspaceStateStore {
|
||||
)
|
||||
if (!this.isLifecycleActive(lifecycleId)) return
|
||||
this.patchState((prev) => applySnapshot(prev, catchUpSnapshot))
|
||||
if (!catchUpSnapshot.full) {
|
||||
for (const envelope of catchUpSnapshot.deltas) {
|
||||
this.notifyEnvelope({
|
||||
seq: envelope.seq,
|
||||
kind: envelope.kind,
|
||||
changed_paths: envelope.changed_paths ?? [],
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.patchState((prev) => ({
|
||||
...prev,
|
||||
@@ -418,6 +452,26 @@ class WorkspaceStateStore {
|
||||
health: "healthy",
|
||||
error: null,
|
||||
}))
|
||||
|
||||
this.notifyEnvelope({
|
||||
seq: event.seq,
|
||||
kind: event.kind,
|
||||
changed_paths: event.changed_paths ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
private notifyEnvelope = (envelope: {
|
||||
seq: number
|
||||
kind: string
|
||||
changed_paths: string[]
|
||||
}) => {
|
||||
for (const listener of this.envelopeListeners) {
|
||||
try {
|
||||
listener(envelope)
|
||||
} catch (error) {
|
||||
console.error("[workspace-state] envelope listener failed", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private patchState = (
|
||||
@@ -509,11 +563,20 @@ export function useWorkspaceStateStore(
|
||||
await store.restart()
|
||||
}, [store])
|
||||
|
||||
const subscribeEnvelopes = useCallback(
|
||||
(listener: WorkspaceEnvelopeListener) => {
|
||||
if (!store) return () => {}
|
||||
return store.subscribeEnvelopes(listener)
|
||||
},
|
||||
[store]
|
||||
)
|
||||
|
||||
if (!rootPath) {
|
||||
return {
|
||||
...EMPTY_STATE,
|
||||
requestResync,
|
||||
restart,
|
||||
subscribeEnvelopes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,5 +584,6 @@ export function useWorkspaceStateStore(
|
||||
...snapshot,
|
||||
requestResync,
|
||||
restart,
|
||||
subscribeEnvelopes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1593,7 +1593,9 @@
|
||||
"forkAndSend": "تفريع وإرسال",
|
||||
"slashCommands": "أوامر الشرطة المائلة",
|
||||
"expertSkills": "مهارات الخبراء",
|
||||
"expertsEmptyForAgent": "لا يحتوي هذا العميل على خبراء مفعّلين. فعّلهم من الإعدادات > الخبراء."
|
||||
"expertsEmptyForAgent": "لا يحتوي هذا العميل على خبراء مفعّلين. فعّلهم من الإعدادات > الخبراء.",
|
||||
"slashSearchPlaceholder": "البحث عن الأوامر...",
|
||||
"slashSearchEmpty": "لا توجد أوامر مطابقة"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "إضافة للقائمة",
|
||||
|
||||
@@ -1593,7 +1593,9 @@
|
||||
"forkAndSend": "Fork & Senden",
|
||||
"slashCommands": "Slash-Befehle",
|
||||
"expertSkills": "Expertenfähigkeiten",
|
||||
"expertsEmptyForAgent": "Dieser Agent hat keine aktivierten Experten. Aktivieren Sie sie unter Einstellungen > Experten."
|
||||
"expertsEmptyForAgent": "Dieser Agent hat keine aktivierten Experten. Aktivieren Sie sie unter Einstellungen > Experten.",
|
||||
"slashSearchPlaceholder": "Befehle suchen...",
|
||||
"slashSearchEmpty": "Keine passenden Befehle"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Zur Warteschlange",
|
||||
|
||||
@@ -1593,7 +1593,9 @@
|
||||
"forkAndSend": "Fork & Send",
|
||||
"slashCommands": "Slash commands",
|
||||
"expertSkills": "Expert skills",
|
||||
"expertsEmptyForAgent": "This agent has no enabled experts. Enable them in Settings > Experts."
|
||||
"expertsEmptyForAgent": "This agent has no enabled experts. Enable them in Settings > Experts.",
|
||||
"slashSearchPlaceholder": "Search commands...",
|
||||
"slashSearchEmpty": "No matching commands"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Queue message",
|
||||
|
||||
@@ -1593,7 +1593,9 @@
|
||||
"forkAndSend": "Fork y Enviar",
|
||||
"slashCommands": "Comandos de barra",
|
||||
"expertSkills": "Habilidades de expertos",
|
||||
"expertsEmptyForAgent": "Este agente no tiene expertos habilitados. Actívalos en Configuración > Expertos."
|
||||
"expertsEmptyForAgent": "Este agente no tiene expertos habilitados. Actívalos en Configuración > Expertos.",
|
||||
"slashSearchPlaceholder": "Buscar comandos...",
|
||||
"slashSearchEmpty": "Sin comandos coincidentes"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Agregar a la cola",
|
||||
|
||||
@@ -1593,7 +1593,9 @@
|
||||
"forkAndSend": "Fork & Envoyer",
|
||||
"slashCommands": "Commandes slash",
|
||||
"expertSkills": "Compétences d'expert",
|
||||
"expertsEmptyForAgent": "Cet agent n'a aucun expert activé. Activez-les dans Paramètres > Experts."
|
||||
"expertsEmptyForAgent": "Cet agent n'a aucun expert activé. Activez-les dans Paramètres > Experts.",
|
||||
"slashSearchPlaceholder": "Rechercher des commandes...",
|
||||
"slashSearchEmpty": "Aucune commande correspondante"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Mettre en file",
|
||||
|
||||
@@ -1593,7 +1593,9 @@
|
||||
"forkAndSend": "フォークして送信",
|
||||
"slashCommands": "スラッシュコマンド",
|
||||
"expertSkills": "エキスパートスキル",
|
||||
"expertsEmptyForAgent": "このエージェントで有効なエキスパートはありません。「設定 > エキスパート」から有効にしてください。"
|
||||
"expertsEmptyForAgent": "このエージェントで有効なエキスパートはありません。「設定 > エキスパート」から有効にしてください。",
|
||||
"slashSearchPlaceholder": "コマンドを検索...",
|
||||
"slashSearchEmpty": "一致するコマンドがありません"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "キューに追加",
|
||||
|
||||
@@ -1593,7 +1593,9 @@
|
||||
"forkAndSend": "포크 & 전송",
|
||||
"slashCommands": "슬래시 명령",
|
||||
"expertSkills": "전문가 스킬",
|
||||
"expertsEmptyForAgent": "이 에이전트에 활성화된 전문가가 없습니다. 설정 > 전문가에서 활성화하세요."
|
||||
"expertsEmptyForAgent": "이 에이전트에 활성화된 전문가가 없습니다. 설정 > 전문가에서 활성화하세요.",
|
||||
"slashSearchPlaceholder": "명령 검색...",
|
||||
"slashSearchEmpty": "일치하는 명령이 없습니다"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "대기열에 추가",
|
||||
|
||||
@@ -1593,7 +1593,9 @@
|
||||
"forkAndSend": "Fork & Enviar",
|
||||
"slashCommands": "Comandos de barra",
|
||||
"expertSkills": "Habilidades de especialistas",
|
||||
"expertsEmptyForAgent": "Este agente não tem especialistas ativados. Ative-os em Configurações > Especialistas."
|
||||
"expertsEmptyForAgent": "Este agente não tem especialistas ativados. Ative-os em Configurações > Especialistas.",
|
||||
"slashSearchPlaceholder": "Buscar comandos...",
|
||||
"slashSearchEmpty": "Nenhum comando correspondente"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Adicionar à fila",
|
||||
|
||||
@@ -1593,7 +1593,9 @@
|
||||
"forkAndSend": "分叉发送",
|
||||
"slashCommands": "斜杠命令",
|
||||
"expertSkills": "专家技能",
|
||||
"expertsEmptyForAgent": "该智能体没有启用任何专家。请在「设置 > 专家」中启用。"
|
||||
"expertsEmptyForAgent": "该智能体没有启用任何专家。请在「设置 > 专家」中启用。",
|
||||
"slashSearchPlaceholder": "搜索命令...",
|
||||
"slashSearchEmpty": "没有匹配的命令"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "加入队列",
|
||||
|
||||
@@ -1593,7 +1593,9 @@
|
||||
"forkAndSend": "分叉發送",
|
||||
"slashCommands": "斜線命令",
|
||||
"expertSkills": "專家技能",
|
||||
"expertsEmptyForAgent": "該智慧代理沒有啟用任何專家。請在「設定 > 專家」中啟用。"
|
||||
"expertsEmptyForAgent": "該智慧代理沒有啟用任何專家。請在「設定 > 專家」中啟用。",
|
||||
"slashSearchPlaceholder": "搜尋命令...",
|
||||
"slashSearchEmpty": "沒有符合的指令"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "加入佇列",
|
||||
|
||||
@@ -880,6 +880,7 @@ export interface WorkspaceDeltaEnvelope {
|
||||
kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string
|
||||
payload: WorkspaceDelta[]
|
||||
requires_resync: boolean
|
||||
changed_paths?: string[]
|
||||
}
|
||||
|
||||
export interface WorkspaceStateEvent {
|
||||
@@ -889,6 +890,7 @@ export interface WorkspaceStateEvent {
|
||||
kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string
|
||||
payload: WorkspaceDelta[]
|
||||
requires_resync: boolean
|
||||
changed_paths?: string[]
|
||||
}
|
||||
|
||||
export interface WorkspaceSnapshotResponse {
|
||||
|
||||
Reference in New Issue
Block a user