Merge branch 'main' into one-folder

This commit is contained in:
xintaofei
2026-04-21 09:27:55 +08:00
20 changed files with 539 additions and 112 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "codeg", "name": "codeg",
"private": true, "private": true,
"version": "0.9.4", "version": "0.9.5",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",

2
src-tauri/Cargo.lock generated
View File

@@ -853,7 +853,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]] [[package]]
name = "codeg" name = "codeg"
version = "0.9.4" version = "0.9.5"
dependencies = [ dependencies = [
"agent-client-protocol-schema", "agent-client-protocol-schema",
"async-trait", "async-trait",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "codeg" name = "codeg"
version = "0.9.4" version = "0.9.5"
description = "Agent Code Generation App" description = "Agent Code Generation App"
authors = ["feitao"] authors = ["feitao"]
edition = "2021" edition = "2021"

View File

@@ -116,8 +116,8 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
name: "Claude Code", name: "Claude Code",
description: "ACP wrapper for Anthropic's Claude", description: "ACP wrapper for Anthropic's Claude",
distribution: AgentDistribution::Npx { distribution: AgentDistribution::Npx {
version: "0.29.2", version: "0.30.0",
package: "@agentclientprotocol/claude-agent-acp@0.29.2", package: "@agentclientprotocol/claude-agent-acp@0.30.0",
cmd: "claude-agent-acp", cmd: "claude-agent-acp",
args: &[], args: &[],
env: &[], env: &[],
@@ -205,34 +205,34 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
name: "OpenCode", name: "OpenCode",
description: "The open source coding agent", description: "The open source coding agent",
distribution: AgentDistribution::Binary { distribution: AgentDistribution::Binary {
version: "1.4.11", version: "1.14.19",
cmd: "opencode", cmd: "opencode",
args: &["acp"], args: &["acp"],
env: &[], env: &[],
platforms: &[ platforms: &[
PlatformBinary { PlatformBinary {
platform: "darwin-aarch64", platform: "darwin-aarch64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-arm64.zip", url: "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-darwin-arm64.zip",
}, },
PlatformBinary { PlatformBinary {
platform: "darwin-x86_64", platform: "darwin-x86_64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip", url: "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-darwin-x64.zip",
}, },
PlatformBinary { PlatformBinary {
platform: "linux-aarch64", platform: "linux-aarch64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz", url: "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-linux-arm64.tar.gz",
}, },
PlatformBinary { PlatformBinary {
platform: "linux-x86_64", platform: "linux-x86_64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-x64.tar.gz", url: "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-linux-x64.tar.gz",
}, },
PlatformBinary { PlatformBinary {
platform: "windows-aarch64", platform: "windows-aarch64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-arm64.zip", url: "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-windows-arm64.zip",
}, },
PlatformBinary { PlatformBinary {
platform: "windows-x86_64", platform: "windows-x86_64",
url: "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-x64.zip", url: "https://github.com/anomalyco/opencode/releases/download/v1.14.19/opencode-windows-x64.zip",
}, },
], ],
}, },

View File

@@ -48,6 +48,8 @@ pub struct WorkspaceDeltaEnvelope {
pub kind: String, pub kind: String,
pub payload: Vec<WorkspaceDelta>, pub payload: Vec<WorkspaceDelta>,
pub requires_resync: bool, pub requires_resync: bool,
#[serde(default)]
pub changed_paths: Vec<String>,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -58,6 +60,8 @@ pub struct WorkspaceStateEvent {
pub kind: String, pub kind: String,
pub payload: Vec<WorkspaceDelta>, pub payload: Vec<WorkspaceDelta>,
pub requires_resync: bool, pub requires_resync: bool,
#[serde(default)]
pub changed_paths: Vec<String>,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -108,6 +112,7 @@ impl WorkspaceStateCore {
kind: String, kind: String,
payload: Vec<WorkspaceDelta>, payload: Vec<WorkspaceDelta>,
requires_resync: bool, requires_resync: bool,
changed_paths: Vec<String>,
) -> WorkspaceStateEvent { ) -> WorkspaceStateEvent {
self.seq += 1; self.seq += 1;
@@ -120,6 +125,7 @@ impl WorkspaceStateCore {
kind: kind.clone(), kind: kind.clone(),
payload: payload.clone(), payload: payload.clone(),
requires_resync, requires_resync,
changed_paths: changed_paths.clone(),
}; };
self.push_recent_event(envelope); self.push_recent_event(envelope);
@@ -130,6 +136,7 @@ impl WorkspaceStateCore {
kind, kind,
payload, payload,
requires_resync, requires_resync,
changed_paths,
} }
} }
@@ -713,6 +720,17 @@ async fn flush_watch_batch(
}); });
} }
// Surface FS activity that doesn't otherwise change tree/git snapshots
// (e.g. files added/removed in a directory beyond WORKSPACE_TREE_MAX_DEPTH,
// or gitignored / non-git-repo changes). The envelope's `changed_paths`
// lets the frontend invalidate its lazy-loaded overrides for deep
// directories without waiting for a manual reload.
if payload.is_empty() && !changed_paths.is_empty() {
payload.push(WorkspaceDelta::Meta {
reason: "fs_events".to_string(),
});
}
if payload.is_empty() { if payload.is_empty() {
return; return;
} }
@@ -731,7 +749,7 @@ async fn flush_watch_batch(
"meta".to_string() "meta".to_string()
}; };
guard.append_event(kind, payload, git_presence_changed) guard.append_event(kind, payload, git_presence_changed, changed_paths)
}; };
emit_event(emitter, "folder://workspace-state-event", event); emit_event(emitter, "folder://workspace-state-event", event);
@@ -1072,6 +1090,7 @@ mod tests {
reason: "boot".to_string(), reason: "boot".to_string(),
}], }],
false, false,
Vec::new(),
); );
let e2 = core.append_event( let e2 = core.append_event(
@@ -1080,6 +1099,7 @@ mod tests {
reason: "tick".to_string(), reason: "tick".to_string(),
}], }],
false, false,
Vec::new(),
); );
assert!(e2.seq > e1.seq); assert!(e2.seq > e1.seq);
@@ -1095,6 +1115,7 @@ mod tests {
reason: "a".to_string(), reason: "a".to_string(),
}], }],
false, false,
Vec::new(),
); );
core.append_event( core.append_event(
@@ -1103,6 +1124,7 @@ mod tests {
reason: "b".to_string(), reason: "b".to_string(),
}], }],
false, false,
Vec::new(),
); );
let snapshot = core.snapshot(Some(e1.seq)); let snapshot = core.snapshot(Some(e1.seq));
@@ -1123,6 +1145,7 @@ mod tests {
reason: "a".to_string(), reason: "a".to_string(),
}], }],
false, false,
Vec::new(),
); );
core.append_event( core.append_event(
"meta".to_string(), "meta".to_string(),
@@ -1130,6 +1153,7 @@ mod tests {
reason: "b".to_string(), reason: "b".to_string(),
}], }],
false, false,
Vec::new(),
); );
let snapshot = core.snapshot(Some(0)); let snapshot = core.snapshot(Some(0));

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "codeg", "productName": "codeg",
"version": "0.9.4", "version": "0.9.5",
"identifier": "app.codeg", "identifier": "app.codeg",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",

View File

@@ -20,6 +20,7 @@ import {
GitFork, GitFork,
ListPlus, ListPlus,
Plus, Plus,
Search,
Send, Send,
Command, Command,
Sparkles, Sparkles,
@@ -543,19 +544,58 @@ export function MessageInput({
() => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)), () => (availableCommands ?? []).filter((cmd) => !expertIdSet.has(cmd.name)),
[availableCommands, expertIdSet] [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(() => { const filteredSlashCommands = useMemo(() => {
if (!slashMenuOpen || slashCommands.length === 0 || slashTriggerPos == null) if (!slashMenuOpen || slashCommands.length === 0 || slashTriggerPos == null)
return [] return []
if (text[slashTriggerPos] !== "/") return [] if (text[slashTriggerPos] !== "/") return []
const afterTrigger = text.slice(slashTriggerPos + 1) const filter = slashFilterText.toLowerCase()
const endIdx = afterTrigger.search(/\s/)
const filter = (
endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx)
).toLowerCase()
return slashCommands.filter((cmd) => 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(() => { const filteredSlashSkills = useMemo(() => {
// Skills autocomplete is Codex-only and triggered by `$`. // Skills autocomplete is Codex-only and triggered by `$`.
if (agentType !== "codex") return [] if (agentType !== "codex") return []
@@ -566,15 +606,26 @@ export function MessageInput({
) )
return [] return []
if (text[slashTriggerPos] !== "$") return [] if (text[slashTriggerPos] !== "$") return []
const afterTrigger = text.slice(slashTriggerPos + 1) const filter = slashFilterText.toLowerCase()
const endIdx = afterTrigger.search(/\s/) if (!filter) return nonExpertSkills
const filter = ( const nameMatches: typeof nonExpertSkills = []
endIdx === -1 ? afterTrigger : afterTrigger.slice(0, endIdx) const idOnlyMatches: typeof nonExpertSkills = []
).toLowerCase() for (const skill of nonExpertSkills) {
return nonExpertSkills.filter((skill) => if (skill.name.toLowerCase().includes(filter)) {
skill.id.toLowerCase().startsWith(filter) nameMatches.push(skill)
) } else if (skill.id.toLowerCase().includes(filter)) {
}, [slashMenuOpen, nonExpertSkills, text, agentType, slashTriggerPos]) idOnlyMatches.push(skill)
}
}
return [...nameMatches, ...idOnlyMatches]
}, [
slashMenuOpen,
nonExpertSkills,
text,
agentType,
slashTriggerPos,
slashFilterText,
])
const slashAutocompleteCount = const slashAutocompleteCount =
filteredSlashCommands.length + filteredSlashSkills.length filteredSlashCommands.length + filteredSlashSkills.length
@@ -950,6 +1001,25 @@ export function MessageInput({
[onModeChange] [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 handleSlashSelect = useCallback((cmd: AvailableCommandInfo) => {
const pos = slashTriggerPosRef.current const pos = slashTriggerPosRef.current
const current = textRef.current const current = textRef.current
@@ -1046,6 +1116,49 @@ export function MessageInput({
[expertPrefix] [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 // 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 // input, never at the cursor. The expert skill is a whole-turn directive
// that the agent inspects first, so prepending keeps semantics unambiguous // that the agent inspects first, so prepending keeps semantics unambiguous
@@ -1719,63 +1832,77 @@ export function MessageInput({
onDrop={handleContainerDrop} onDrop={handleContainerDrop}
> >
{slashMenuOpen && slashAutocompleteCount > 0 && ( {slashMenuOpen && slashAutocompleteCount > 0 && (
<div <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">
ref={slashMenuListRef} <div className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2">
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" <Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
> <input
{filteredSlashCommands.map((cmd, i) => ( type="text"
<button role="searchbox"
key={`cmd-${cmd.name}`} aria-label={t("slashSearchPlaceholder")}
type="button" value={slashFilterText}
className={cn( onChange={handleSlashSearchChange}
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm", onKeyDown={handleSlashSearchKeyDown}
i === slashSelectedIndex placeholder={t("slashSearchPlaceholder")}
? "bg-accent text-accent-foreground" className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
: "hover:bg-muted" autoComplete="off"
)} spellCheck={false}
onMouseDown={(e) => { />
e.preventDefault() </div>
handleSlashSelect(cmd) <div ref={slashMenuListRef} className="flex-1 overflow-y-auto p-1">
}} {filteredSlashCommands.map((cmd, i) => (
>
<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 <button
key={`skill-${skill.scope}-${skill.id}`} key={`cmd-${cmd.name}`}
type="button" type="button"
className={cn( className={cn(
"flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm", "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" ? "bg-accent text-accent-foreground"
: "hover:bg-muted" : "hover:bg-muted"
)} )}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault() e.preventDefault()
handleSkillAutocompleteSelect(skill) handleSlashSelect(cmd)
}} }}
> >
<BookOpenText className="mt-0.5 size-4 shrink-0 text-primary/80" /> <span className="shrink-0 font-mono text-primary">
<div className="min-w-0 flex-1"> /{cmd.name}
<div className="flex items-center gap-1.5"> </span>
<span className="truncate font-medium">{skill.name}</span> <span className="truncate text-xs text-muted-foreground">
<span className="shrink-0 font-mono text-[11px] text-muted-foreground"> {cmd.description}
{expertPrefix} </span>
{skill.id}
</span>
</div>
</div>
</button> </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> </div>
)} )}
{atMenuOpen && filteredAtFiles.length > 0 && ( {atMenuOpen && filteredAtFiles.length > 0 && (
@@ -1946,7 +2073,10 @@ export function MessageInput({
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<DropdownMenu> <DropdownMenu
open={slashDropdownOpen}
onOpenChange={handleSlashDropdownOpenChange}
>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
disabled={disabled || slashCommands.length === 0} disabled={disabled || slashCommands.length === 0}
@@ -1965,23 +2095,82 @@ export function MessageInput({
<DropdownMenuContent <DropdownMenuContent
side="top" side="top"
align="start" align="start"
className="min-w-72 overflow-y-auto" className="flex min-w-72 flex-col overflow-hidden p-0"
style={{ style={{
maxHeight: maxHeight:
"min(32rem, var(--radix-dropdown-menu-content-available-height))", "min(32rem, var(--radix-dropdown-menu-content-available-height))",
}} }}
{...slashDropdownFocusProps}
> >
{slashCommands.map((cmd) => ( <div className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2">
<DropdownMenuItem <Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
key={cmd.name} <input
onClick={() => handleSlashPopoverSelect(cmd)} ref={slashDropdownInputRef}
> type="text"
<DropdownRadioItemContent role="searchbox"
label={`/${cmd.name}`} aria-label={t("slashSearchPlaceholder")}
description={cmd.description} value={slashDropdownSearch}
/> onChange={(e) => setSlashDropdownSearch(e.target.value)}
</DropdownMenuItem> 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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* 宽屏内联显示,窄屏(<480px通过"更多"气泡显示 */} {/* 宽屏内联显示,窄屏(<480px通过"更多"气泡显示 */}

View File

@@ -888,6 +888,11 @@ export function FileTreeTab() {
new Map() new Map()
) )
const lazyLoadingDirPathsRef = useRef<Set<string>>(new Set()) 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>>( const externalConflictSignatureByPathRef = useRef<Map<string, string>>(
new Map() new Map()
) )
@@ -967,36 +972,58 @@ export function FileTreeTab() {
return 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") 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] [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(() => { useEffect(() => {
workspaceTreeRef.current = workspaceState.tree
setNodes( setNodes(
applyLazyTreeOverrides( applyLazyTreeOverrides(
workspaceState.tree, workspaceState.tree,
lazyLoadedChildrenByPathRef.current lazyLoadedChildrenByPathRef.current
) )
) )
}, [folder?.path, workspaceState.tree])
useEffect(() => {
const nextStatusByPath = new Map<string, string>() const nextStatusByPath = new Map<string, string>()
for (const entry of workspaceState.git) { for (const entry of workspaceState.git) {
nextStatusByPath.set(entry.path, entry.status) nextStatusByPath.set(entry.path, entry.status)
} }
setGitStatusByPath(nextStatusByPath) setGitStatusByPath(nextStatusByPath)
setGitEnabled(true) setGitEnabled(true)
}, [workspaceState.git])
useEffect(() => {
setLoading( setLoading(
workspaceState.health === "resyncing" && workspaceState.seq === 0 workspaceState.health === "resyncing" && workspaceState.seq === 0
) )
setError(workspaceState.health === "degraded" ? workspaceState.error : null) setError(workspaceState.health === "degraded" ? workspaceState.error : null)
}, [ }, [workspaceState.error, workspaceState.health, workspaceState.seq])
folder?.path,
workspaceState.error,
workspaceState.git,
workspaceState.health,
workspaceState.seq,
workspaceState.tree,
])
const loadDirectoryChildren = useCallback( const loadDirectoryChildren = useCallback(
async (dirPath: string) => { async (dirPath: string) => {
@@ -1007,12 +1034,19 @@ export function FileTreeTab() {
if (lazyLoadedChildrenByPathRef.current.has(normalizedDirPath)) return if (lazyLoadedChildrenByPathRef.current.has(normalizedDirPath)) return
if (lazyLoadingDirPathsRef.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) { if (existingChildren && existingChildren.length > 0) {
lazyLoadedChildrenByPathRef.current.set(
normalizedDirPath,
existingChildren
)
return return
} }
@@ -1033,9 +1067,103 @@ export function FileTreeTab() {
lazyLoadingDirPathsRef.current.delete(normalizedDirPath) 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(() => { useEffect(() => {
const previousExpanded = previousExpandedPathsRef.current const previousExpanded = previousExpandedPathsRef.current
for (const path of expandedPaths) { for (const path of expandedPaths) {

View File

@@ -30,9 +30,16 @@ export interface WorkspaceStateView {
isGitRepo: boolean isGitRepo: boolean
} }
export type WorkspaceEnvelopeListener = (envelope: {
seq: number
kind: string
changed_paths: string[]
}) => void
export interface WorkspaceStateResult extends WorkspaceStateView { export interface WorkspaceStateResult extends WorkspaceStateView {
requestResync: (reason?: string) => Promise<void> requestResync: (reason?: string) => Promise<void>
restart: () => Promise<void> restart: () => Promise<void>
subscribeEnvelopes: (listener: WorkspaceEnvelopeListener) => () => void
} }
const WORKSPACE_PROTOCOL_VERSION = 1 const WORKSPACE_PROTOCOL_VERSION = 1
@@ -140,6 +147,7 @@ class WorkspaceStateStore {
private readonly rootPath: string private readonly rootPath: string
private readonly normalizedRootPath: string private readonly normalizedRootPath: string
private listeners = new Set<() => void>() private listeners = new Set<() => void>()
private envelopeListeners = new Set<WorkspaceEnvelopeListener>()
private state: WorkspaceStateView private state: WorkspaceStateView
private refCount = 0 private refCount = 0
private started = false private started = false
@@ -171,6 +179,13 @@ class WorkspaceStateStore {
} }
} }
subscribeEnvelopes = (listener: WorkspaceEnvelopeListener): (() => void) => {
this.envelopeListeners.add(listener)
return () => {
this.envelopeListeners.delete(listener)
}
}
acquire = () => { acquire = () => {
this.cancelPendingShutdown() this.cancelPendingShutdown()
this.cancelEviction() this.cancelEviction()
@@ -212,6 +227,16 @@ class WorkspaceStateStore {
this.patchState((prev) => applySnapshot(prev, snapshot)) this.patchState((prev) => applySnapshot(prev, snapshot))
if (snapshot.full) { if (snapshot.full) {
this.hasBaselineSnapshot = true 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) { } catch (error) {
this.patchState((prev) => ({ this.patchState((prev) => ({
@@ -315,6 +340,15 @@ class WorkspaceStateStore {
) )
if (!this.isLifecycleActive(lifecycleId)) return if (!this.isLifecycleActive(lifecycleId)) return
this.patchState((prev) => applySnapshot(prev, catchUpSnapshot)) 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) { } catch (error) {
this.patchState((prev) => ({ this.patchState((prev) => ({
...prev, ...prev,
@@ -418,6 +452,26 @@ class WorkspaceStateStore {
health: "healthy", health: "healthy",
error: null, 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 = ( private patchState = (
@@ -509,11 +563,20 @@ export function useWorkspaceStateStore(
await store.restart() await store.restart()
}, [store]) }, [store])
const subscribeEnvelopes = useCallback(
(listener: WorkspaceEnvelopeListener) => {
if (!store) return () => {}
return store.subscribeEnvelopes(listener)
},
[store]
)
if (!rootPath) { if (!rootPath) {
return { return {
...EMPTY_STATE, ...EMPTY_STATE,
requestResync, requestResync,
restart, restart,
subscribeEnvelopes,
} }
} }
@@ -521,5 +584,6 @@ export function useWorkspaceStateStore(
...snapshot, ...snapshot,
requestResync, requestResync,
restart, restart,
subscribeEnvelopes,
} }
} }

View File

@@ -1593,7 +1593,9 @@
"forkAndSend": "تفريع وإرسال", "forkAndSend": "تفريع وإرسال",
"slashCommands": "أوامر الشرطة المائلة", "slashCommands": "أوامر الشرطة المائلة",
"expertSkills": "مهارات الخبراء", "expertSkills": "مهارات الخبراء",
"expertsEmptyForAgent": "لا يحتوي هذا العميل على خبراء مفعّلين. فعّلهم من الإعدادات > الخبراء." "expertsEmptyForAgent": "لا يحتوي هذا العميل على خبراء مفعّلين. فعّلهم من الإعدادات > الخبراء.",
"slashSearchPlaceholder": "البحث عن الأوامر...",
"slashSearchEmpty": "لا توجد أوامر مطابقة"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "إضافة للقائمة", "addToQueue": "إضافة للقائمة",

View File

@@ -1593,7 +1593,9 @@
"forkAndSend": "Fork & Senden", "forkAndSend": "Fork & Senden",
"slashCommands": "Slash-Befehle", "slashCommands": "Slash-Befehle",
"expertSkills": "Expertenfähigkeiten", "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": { "messageQueue": {
"addToQueue": "Zur Warteschlange", "addToQueue": "Zur Warteschlange",

View File

@@ -1593,7 +1593,9 @@
"forkAndSend": "Fork & Send", "forkAndSend": "Fork & Send",
"slashCommands": "Slash commands", "slashCommands": "Slash commands",
"expertSkills": "Expert skills", "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": { "messageQueue": {
"addToQueue": "Queue message", "addToQueue": "Queue message",

View File

@@ -1593,7 +1593,9 @@
"forkAndSend": "Fork y Enviar", "forkAndSend": "Fork y Enviar",
"slashCommands": "Comandos de barra", "slashCommands": "Comandos de barra",
"expertSkills": "Habilidades de expertos", "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": { "messageQueue": {
"addToQueue": "Agregar a la cola", "addToQueue": "Agregar a la cola",

View File

@@ -1593,7 +1593,9 @@
"forkAndSend": "Fork & Envoyer", "forkAndSend": "Fork & Envoyer",
"slashCommands": "Commandes slash", "slashCommands": "Commandes slash",
"expertSkills": "Compétences d'expert", "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": { "messageQueue": {
"addToQueue": "Mettre en file", "addToQueue": "Mettre en file",

View File

@@ -1593,7 +1593,9 @@
"forkAndSend": "フォークして送信", "forkAndSend": "フォークして送信",
"slashCommands": "スラッシュコマンド", "slashCommands": "スラッシュコマンド",
"expertSkills": "エキスパートスキル", "expertSkills": "エキスパートスキル",
"expertsEmptyForAgent": "このエージェントで有効なエキスパートはありません。「設定 > エキスパート」から有効にしてください。" "expertsEmptyForAgent": "このエージェントで有効なエキスパートはありません。「設定 > エキスパート」から有効にしてください。",
"slashSearchPlaceholder": "コマンドを検索...",
"slashSearchEmpty": "一致するコマンドがありません"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "キューに追加", "addToQueue": "キューに追加",

View File

@@ -1593,7 +1593,9 @@
"forkAndSend": "포크 & 전송", "forkAndSend": "포크 & 전송",
"slashCommands": "슬래시 명령", "slashCommands": "슬래시 명령",
"expertSkills": "전문가 스킬", "expertSkills": "전문가 스킬",
"expertsEmptyForAgent": "이 에이전트에 활성화된 전문가가 없습니다. 설정 > 전문가에서 활성화하세요." "expertsEmptyForAgent": "이 에이전트에 활성화된 전문가가 없습니다. 설정 > 전문가에서 활성화하세요.",
"slashSearchPlaceholder": "명령 검색...",
"slashSearchEmpty": "일치하는 명령이 없습니다"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "대기열에 추가", "addToQueue": "대기열에 추가",

View File

@@ -1593,7 +1593,9 @@
"forkAndSend": "Fork & Enviar", "forkAndSend": "Fork & Enviar",
"slashCommands": "Comandos de barra", "slashCommands": "Comandos de barra",
"expertSkills": "Habilidades de especialistas", "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": { "messageQueue": {
"addToQueue": "Adicionar à fila", "addToQueue": "Adicionar à fila",

View File

@@ -1593,7 +1593,9 @@
"forkAndSend": "分叉发送", "forkAndSend": "分叉发送",
"slashCommands": "斜杠命令", "slashCommands": "斜杠命令",
"expertSkills": "专家技能", "expertSkills": "专家技能",
"expertsEmptyForAgent": "该智能体没有启用任何专家。请在「设置 > 专家」中启用。" "expertsEmptyForAgent": "该智能体没有启用任何专家。请在「设置 > 专家」中启用。",
"slashSearchPlaceholder": "搜索命令...",
"slashSearchEmpty": "没有匹配的命令"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "加入队列", "addToQueue": "加入队列",

View File

@@ -1593,7 +1593,9 @@
"forkAndSend": "分叉發送", "forkAndSend": "分叉發送",
"slashCommands": "斜線命令", "slashCommands": "斜線命令",
"expertSkills": "專家技能", "expertSkills": "專家技能",
"expertsEmptyForAgent": "該智慧代理沒有啟用任何專家。請在「設定 > 專家」中啟用。" "expertsEmptyForAgent": "該智慧代理沒有啟用任何專家。請在「設定 > 專家」中啟用。",
"slashSearchPlaceholder": "搜尋命令...",
"slashSearchEmpty": "沒有符合的指令"
}, },
"messageQueue": { "messageQueue": {
"addToQueue": "加入佇列", "addToQueue": "加入佇列",

View File

@@ -880,6 +880,7 @@ export interface WorkspaceDeltaEnvelope {
kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string
payload: WorkspaceDelta[] payload: WorkspaceDelta[]
requires_resync: boolean requires_resync: boolean
changed_paths?: string[]
} }
export interface WorkspaceStateEvent { export interface WorkspaceStateEvent {
@@ -889,6 +890,7 @@ export interface WorkspaceStateEvent {
kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string kind: "fs_delta" | "git_delta" | "meta" | "resync_hint" | string
payload: WorkspaceDelta[] payload: WorkspaceDelta[]
requires_resync: boolean requires_resync: boolean
changed_paths?: string[]
} }
export interface WorkspaceSnapshotResponse { export interface WorkspaceSnapshotResponse {