Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions

View File

@@ -0,0 +1,362 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { useAcpActions } from "@/contexts/acp-connections-context"
import { useTaskContext } from "@/contexts/task-context"
import { useConnection, type UseConnectionReturn } from "@/hooks/use-connection"
import { AGENT_LABELS, type AgentType, type PromptDraft } from "@/lib/types"
import { getPromptDraftDisplayText } from "@/lib/prompt-draft"
import {
clearPendingPromptText,
setPendingPromptText,
} from "@/lib/pending-prompt-text"
interface UseConnectionLifecycleOptions {
contextKey: string
agentType: AgentType
isActive: boolean
workingDir?: string
sessionId?: string
}
export interface UseConnectionLifecycleReturn {
conn: UseConnectionReturn
modeLoading: boolean
configOptionsLoading: boolean
autoConnectError: string | null
handleFocus: () => void
handleSend: (draft: PromptDraft, modeId?: string | null) => void
handleSetConfigOption: (configId: string, valueId: string) => void
handleCancel: () => void
handleRespondPermission: (requestId: string, optionId: string) => void
}
function normalizeErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
function isExpectedAutoLinkError(error: unknown): boolean {
if (!error || typeof error !== "object") return false
return (error as { alerted?: unknown }).alerted === true
}
export function useConnectionLifecycle({
contextKey,
agentType,
isActive,
workingDir,
sessionId,
}: UseConnectionLifecycleOptions): UseConnectionLifecycleReturn {
const { setActiveKey, touchActivity } = useAcpActions()
const { addTask, updateTask, removeTask } = useTaskContext()
const conn = useConnection(contextKey)
// Destructure stable callbacks (depend only on actions + contextKey)
// vs. volatile derived state (status, liveMessage, etc.)
const {
status,
selectorsReady,
connect: connConnect,
sendPrompt,
setMode: connSetMode,
setConfigOption: connSetConfigOption,
cancel: connCancel,
respondPermission: connRespondPermission,
modes,
configOptions,
} = conn
const isInteractiveStatus = status === "connected" || status === "prompting"
const effectiveSelectorsReady =
selectorsReady || modes !== null || configOptions !== null
const selectorTaskIdRef = useRef<string | null>(null)
const selectorTaskTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
)
const selectorTaskSuppressedRef = useRef(false)
const modeLoading =
status === "connecting" ||
status === "downloading" ||
(isInteractiveStatus && !effectiveSelectorsReady)
const configOptionsLoading =
status === "connecting" ||
status === "downloading" ||
(isInteractiveStatus && !effectiveSelectorsReady)
const [lastAutoConnectError, setLastAutoConnectError] = useState<{
contextKey: string
agentType: AgentType
message: string
} | null>(null)
// Refs for auto-connect effect, which intentionally avoids volatile
// dependencies to prevent reconnect loops. Synced via useEffect —
// effects run in declaration order, so these are current before
// the auto-connect effect reads them.
const statusRef = useRef(status)
useEffect(() => {
statusRef.current = status
}, [status])
const contextKeyRef = useRef(contextKey)
useEffect(() => {
contextKeyRef.current = contextKey
}, [contextKey])
const connConnectRef = useRef(connConnect)
useEffect(() => {
connConnectRef.current = connConnect
}, [connConnect])
const agentTypeRef = useRef(agentType)
useEffect(() => {
agentTypeRef.current = agentType
}, [agentType])
const sessionIdRef = useRef(sessionId)
useEffect(() => {
sessionIdRef.current = sessionId
}, [sessionId])
const modeIdRef = useRef<string | null>(modes?.current_mode_id ?? null)
useEffect(() => {
modeIdRef.current = modes?.current_mode_id ?? null
}, [modes?.current_mode_id])
// Sync activeKey when this view is the active tab
useEffect(() => {
if (isActive && contextKey) {
setActiveKey(contextKey)
touchActivity(contextKey)
}
}, [isActive, contextKey, setActiveKey, touchActivity])
// Auto-connect when tab becomes active and workingDir is available.
// Depends on isActive + workingDir so that connections wait for folder
// info to load (workingDir transitions from undefined → folder.path).
// Status changes must NOT re-trigger this to avoid infinite reconnect
// loops on transient errors.
useEffect(() => {
if (!isActive) return
if (!workingDir) return
let cancelled = false
const s = statusRef.current
if (!s || s === "disconnected" || s === "error") {
connConnectRef
.current(agentTypeRef.current, workingDir, sessionIdRef.current, {
source: "auto_link",
})
.then(() => {
if (!cancelled) {
setLastAutoConnectError(null)
}
})
.catch((e: unknown) => {
if (!cancelled) {
setLastAutoConnectError({
contextKey: contextKeyRef.current,
agentType: agentTypeRef.current,
message: normalizeErrorMessage(e),
})
}
if (!isExpectedAutoLinkError(e)) {
console.error("[ConnLifecycle] auto-connect:", e)
}
})
}
return () => {
cancelled = true
}
}, [isActive, workingDir])
// Manage task status for connection progress
const taskIdRef = useRef<string | null>(null)
useEffect(() => {
if (status === "connecting" || status === "downloading") {
if (!taskIdRef.current) {
const id = `acp-connect-${Date.now()}`
taskIdRef.current = id
const agent = AGENT_LABELS[agentType]
addTask(id, `Connecting to ${agent}`, `Establishing connection`)
}
updateTask(taskIdRef.current, { status: "running" })
} else if (status === "connected" || status === "prompting") {
if (taskIdRef.current) {
updateTask(taskIdRef.current, { status: "completed" })
taskIdRef.current = null
}
} else if (status === "error") {
if (taskIdRef.current) {
updateTask(taskIdRef.current, {
status: "failed",
error: "Connection failed",
})
taskIdRef.current = null
}
} else if (status === "disconnected" || status === null) {
if (taskIdRef.current) {
removeTask(taskIdRef.current)
taskIdRef.current = null
}
}
}, [status, addTask, updateTask, removeTask, agentType])
useEffect(() => {
if (status === "prompting") return
clearPendingPromptText(contextKey)
}, [status, contextKey])
const clearSelectorTask = useCallback(() => {
if (selectorTaskTimeoutRef.current) {
clearTimeout(selectorTaskTimeoutRef.current)
selectorTaskTimeoutRef.current = null
}
if (selectorTaskIdRef.current) {
removeTask(selectorTaskIdRef.current)
selectorTaskIdRef.current = null
}
}, [removeTask])
useEffect(() => {
const isInteractive = status === "connected" || status === "prompting"
if (!isInteractive) {
selectorTaskSuppressedRef.current = false
clearSelectorTask()
return
}
if (selectorTaskSuppressedRef.current) {
clearSelectorTask()
return
}
const hasSelectorLoading = !effectiveSelectorsReady
if (!hasSelectorLoading) {
clearSelectorTask()
return
}
if (!selectorTaskIdRef.current) {
const id = `acp-selectors-${Date.now()}`
selectorTaskIdRef.current = id
const agent = AGENT_LABELS[agentType]
addTask(
id,
`Loading ${agent} selectors`,
"Fetching mode and session config options"
)
updateTask(id, { status: "running" })
}
if (!selectorTaskTimeoutRef.current) {
selectorTaskTimeoutRef.current = setTimeout(() => {
selectorTaskTimeoutRef.current = null
selectorTaskSuppressedRef.current = true
clearSelectorTask()
}, 5000)
}
}, [
status,
effectiveSelectorsReady,
modes,
configOptions,
agentType,
addTask,
updateTask,
clearSelectorTask,
])
// Clean up lingering task on unmount (e.g. tab closed while connecting)
useEffect(() => {
return () => {
if (taskIdRef.current) {
removeTask(taskIdRef.current)
}
selectorTaskSuppressedRef.current = false
clearSelectorTask()
}
}, [removeTask, clearSelectorTask])
const handleFocus = useCallback(() => {
touchActivity(contextKey)
if (!status || status === "disconnected" || status === "error") {
setLastAutoConnectError(null)
connConnect(agentType, workingDir, sessionId, {
source: "auto_link",
}).catch((e: unknown) => {
if (!isExpectedAutoLinkError(e)) {
console.error("[ConnLifecycle] connect:", e)
}
})
}
}, [
agentType,
workingDir,
sessionId,
status,
connConnect,
contextKey,
touchActivity,
])
const autoConnectError =
status === "connected" || status === "prompting"
? null
: lastAutoConnectError?.contextKey === contextKey &&
lastAutoConnectError.agentType === agentType
? lastAutoConnectError.message
: null
// sendPrompt, connCancel, connRespondPermission are stable (depend
// only on actions + contextKey), so these callbacks are effectively stable.
const handleSend = useCallback(
(draft: PromptDraft, modeId?: string | null) => {
touchActivity(contextKey)
setPendingPromptText(contextKey, getPromptDraftDisplayText(draft))
void (async () => {
const currentModeId = modeIdRef.current
if (modeId && modeId !== currentModeId) {
await connSetMode(modeId)
// Optimistically track selected mode to avoid duplicate set_mode
// calls before CurrentModeUpdate arrives from the agent.
modeIdRef.current = modeId
}
await sendPrompt(draft.blocks)
})().catch((e: unknown) =>
console.error("[ConnLifecycle] sendPrompt:", e)
)
},
[connSetMode, sendPrompt, contextKey, touchActivity]
)
const handleCancel = useCallback(() => {
connCancel().catch((e: unknown) =>
console.error("[ConnLifecycle] cancel:", e)
)
}, [connCancel])
const handleSetConfigOption = useCallback(
(configId: string, valueId: string) => {
touchActivity(contextKey)
connSetConfigOption(configId, valueId).catch((e: unknown) =>
console.error("[ConnLifecycle] setConfigOption:", e)
)
},
[connSetConfigOption, contextKey, touchActivity]
)
const handleRespondPermission = useCallback(
(requestId: string, optionId: string) => {
touchActivity(contextKey)
connRespondPermission(requestId, optionId).catch((e: unknown) =>
console.error("[ConnLifecycle] respondPermission:", e)
)
},
[connRespondPermission, contextKey, touchActivity]
)
return {
conn,
modeLoading,
configOptionsLoading,
autoConnectError,
handleFocus,
handleSend,
handleSetConfigOption,
handleCancel,
handleRespondPermission,
}
}

158
src/hooks/use-connection.ts Normal file
View File

@@ -0,0 +1,158 @@
"use client"
import { useCallback, useMemo, useSyncExternalStore } from "react"
import {
useAcpActions,
useConnectionStore,
type ConnectionState,
type ConnectOptions,
type LiveMessage,
type PendingPermission,
} from "@/contexts/acp-connections-context"
import type {
AgentType,
AvailableCommandInfo,
ConnectionStatus,
SessionConfigOptionInfo,
SessionModeStateInfo,
PromptInputBlock,
} from "@/lib/types"
export interface UseConnectionReturn {
connectionId: string | null
status: ConnectionStatus | null
selectorsReady: boolean
sessionId: string | null
modes: SessionModeStateInfo | null
configOptions: SessionConfigOptionInfo[] | null
availableCommands: AvailableCommandInfo[] | null
liveMessage: LiveMessage | null
pendingPermission: PendingPermission | null
error: string | null
connect: (
agentType: AgentType,
workingDir?: string,
sessionId?: string,
options?: ConnectOptions
) => Promise<void>
disconnect: () => Promise<void>
sendPrompt: (blocks: PromptInputBlock[]) => Promise<void>
setMode: (modeId: string) => Promise<void>
setConfigOption: (configId: string, valueId: string) => Promise<void>
cancel: () => Promise<void>
respondPermission: (requestId: string, optionId: string) => Promise<void>
}
function derive(conn: ConnectionState | undefined) {
if (!conn) return null
return conn
}
export function useConnection(contextKey: string): UseConnectionReturn {
const store = useConnectionStore()
const actions = useAcpActions()
const subscribe = useCallback(
(cb: () => void) => store.subscribeKey(contextKey, cb),
[store, contextKey]
)
const getSnapshot = useCallback(
() => derive(store.getConnection(contextKey)),
[store, contextKey]
)
const connection = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const connectionId = connection?.connectionId ?? null
const status = connection?.status ?? null
const selectorsReady = connection?.selectorsReady ?? false
const sessionId = connection?.sessionId ?? null
const modes = connection?.modes ?? null
const configOptions = connection?.configOptions ?? null
const availableCommands = connection?.availableCommands ?? null
const liveMessage = connection?.liveMessage ?? null
const pendingPermission = connection?.pendingPermission ?? null
const error = connection?.error ?? null
const connect = useCallback(
(
agentType: AgentType,
workingDir?: string,
sessionId?: string,
options?: ConnectOptions
) => actions.connect(contextKey, agentType, workingDir, sessionId, options),
[actions, contextKey]
)
const disconnect = useCallback(
() => actions.disconnect(contextKey),
[actions, contextKey]
)
const sendPrompt = useCallback(
(blocks: PromptInputBlock[]) => actions.sendPrompt(contextKey, blocks),
[actions, contextKey]
)
const setMode = useCallback(
(modeId: string) => actions.setMode(contextKey, modeId),
[actions, contextKey]
)
const setConfigOption = useCallback(
(configId: string, valueId: string) =>
actions.setConfigOption(contextKey, configId, valueId),
[actions, contextKey]
)
const cancel = useCallback(
() => actions.cancel(contextKey),
[actions, contextKey]
)
const respondPermission = useCallback(
(requestId: string, optionId: string) =>
actions.respondPermission(contextKey, requestId, optionId),
[actions, contextKey]
)
return useMemo(
() => ({
connectionId,
status,
selectorsReady,
sessionId,
modes,
configOptions,
availableCommands,
liveMessage,
pendingPermission,
error,
connect,
disconnect,
sendPrompt,
setMode,
setConfigOption,
cancel,
respondPermission,
}),
[
connectionId,
status,
selectorsReady,
sessionId,
modes,
configOptions,
availableCommands,
liveMessage,
pendingPermission,
error,
connect,
disconnect,
sendPrompt,
setMode,
setConfigOption,
cancel,
respondPermission,
]
)
}

View File

@@ -0,0 +1,162 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { getFolderConversation } from "@/lib/tauri"
import type { DbConversationDetail } from "@/lib/types"
// Module-level cache: survives component unmount/remount
const detailCache = new Map<number, DbConversationDetail>()
const detailListeners = new Map<
number,
Set<(detail: DbConversationDetail) => void>
>()
function publishDetail(conversationId: number, detail: DbConversationDetail) {
const listeners = detailListeners.get(conversationId)
if (!listeners || listeners.size === 0) return
for (const listener of listeners) {
listener(detail)
}
}
function setCachedDetail(conversationId: number, detail: DbConversationDetail) {
detailCache.set(conversationId, detail)
publishDetail(conversationId, detail)
}
function subscribeDetail(
conversationId: number,
listener: (detail: DbConversationDetail) => void
) {
let listeners = detailListeners.get(conversationId)
if (!listeners) {
listeners = new Set()
detailListeners.set(conversationId, listeners)
}
listeners.add(listener)
return () => {
const current = detailListeners.get(conversationId)
if (!current) return
current.delete(listener)
if (current.size === 0) {
detailListeners.delete(conversationId)
}
}
}
/** Invalidate cached detail so the next mount re-fetches from disk. */
export function invalidateDetailCache(conversationId: number) {
detailCache.delete(conversationId)
}
interface State {
key: number
detail: DbConversationDetail | null
loading: boolean
error: string | null
fetchSeq: number
}
export function useDbMessageDetail(conversationId: number) {
const getCachedState = useCallback((id: number): State => {
const cached = detailCache.get(id)
return {
key: id,
detail: cached ?? null,
loading: !cached,
error: null,
fetchSeq: 0,
}
}, [])
const [state, setState] = useState<State>(() => {
return getCachedState(conversationId)
})
const derivedState =
state.key === conversationId ? state : getCachedState(conversationId)
useEffect(
() =>
subscribeDetail(conversationId, (detail) => {
setState((prev) =>
prev.key === conversationId
? { ...prev, detail, loading: false, error: null }
: prev
)
}),
[conversationId]
)
const refetch = useCallback(() => {
detailCache.delete(conversationId)
setState((prev) => {
const base =
prev.key === conversationId ? prev : getCachedState(conversationId)
return {
...base,
key: conversationId,
loading: true,
error: null,
fetchSeq: base.fetchSeq + 1,
}
})
}, [conversationId, getCachedState])
useEffect(() => {
// Skip fetch if cache already has data
if (detailCache.has(conversationId)) return
let cancelled = false
getFolderConversation(conversationId)
.then((d) => {
setCachedDetail(conversationId, d)
if (!cancelled) {
setState((prev) =>
prev.key === conversationId
? { ...prev, detail: d, loading: false, error: null }
: {
key: conversationId,
detail: d,
loading: false,
error: null,
fetchSeq: 0,
}
)
}
})
.catch((e) => {
if (!cancelled) {
setState((prev) =>
prev.key === conversationId
? {
...prev,
error: e instanceof Error ? e.message : String(e),
loading: false,
}
: {
key: conversationId,
detail: null,
loading: false,
error: e instanceof Error ? e.message : String(e),
fetchSeq: 0,
}
)
}
})
return () => {
cancelled = true
}
}, [conversationId, derivedState.fetchSeq])
return useMemo(
() => ({
detail: derivedState.detail,
loading: derivedState.loading,
error: derivedState.error,
refetch,
}),
[derivedState.detail, derivedState.loading, derivedState.error, refetch]
)
}

8
src/hooks/use-is-mac.ts Normal file
View File

@@ -0,0 +1,8 @@
"use client"
import { usePlatform } from "./use-platform"
export function useIsMac(): boolean {
const { isMac } = usePlatform()
return isMac
}

View File

@@ -0,0 +1,60 @@
"use client"
import { useEffect, useState } from "react"
import { getConversation } from "@/lib/tauri"
import type { AgentType, ConversationDetail } from "@/lib/types"
interface MessageDetailState {
key: string
detail: ConversationDetail | null
loading: boolean
error: string | null
}
function makeKey(agentType: AgentType, conversationId: string): string {
return `${agentType}:${conversationId}`
}
export function useMessageDetail(agentType: AgentType, conversationId: string) {
const key = makeKey(agentType, conversationId)
const [state, setState] = useState<MessageDetailState>({
key,
detail: null,
loading: true,
error: null,
})
// Reset when key changes (single setState instead of 4)
if (state.key !== key) {
setState({ key, detail: null, loading: true, error: null })
}
useEffect(() => {
let cancelled = false
getConversation(agentType, conversationId)
.then((d) => {
if (!cancelled) {
setState((prev) => ({ ...prev, detail: d, loading: false }))
}
})
.catch((e) => {
if (!cancelled) {
setState((prev) => ({
...prev,
error: e instanceof Error ? e.message : String(e),
loading: false,
}))
}
})
return () => {
cancelled = true
}
}, [agentType, conversationId])
return {
detail: state.detail,
loading: state.loading,
error: state.error,
}
}

51
src/hooks/use-platform.ts Normal file
View File

@@ -0,0 +1,51 @@
"use client"
import { useEffect, useState } from "react"
export type PlatformType = "macos" | "windows" | "linux" | "unknown"
function detectPlatform(): PlatformType {
if (typeof navigator === "undefined") return "unknown"
const platform = navigator.platform.toLowerCase()
const userAgent = navigator.userAgent.toLowerCase()
if (platform.includes("mac") || userAgent.includes("mac os")) {
return "macos"
}
if (platform.includes("win") || userAgent.includes("windows")) {
return "windows"
}
if (
platform.includes("linux") ||
userAgent.includes("linux") ||
userAgent.includes("x11")
) {
return "linux"
}
return "unknown"
}
export function usePlatform() {
const [platform, setPlatform] = useState<PlatformType>("unknown")
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
setPlatform(detectPlatform())
})
return () => {
window.cancelAnimationFrame(frame)
}
}, [])
return {
platform,
isMac: platform === "macos",
isWindows: platform === "windows",
isLinux: platform === "linux",
}
}

View File

@@ -0,0 +1,75 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import {
DEFAULT_SHORTCUTS,
SHORTCUTS_STORAGE_KEY,
SHORTCUTS_UPDATED_EVENT,
type ShortcutActionId,
type ShortcutSettings,
normalizeShortcut,
readShortcutSettings,
writeShortcutSettings,
} from "@/lib/keyboard-shortcuts"
interface UseShortcutSettingsResult {
shortcuts: ShortcutSettings
updateShortcut: (actionId: ShortcutActionId, shortcut: string) => boolean
resetShortcuts: () => void
}
export function useShortcutSettings(): UseShortcutSettingsResult {
const [shortcuts, setShortcuts] =
useState<ShortcutSettings>(DEFAULT_SHORTCUTS)
useEffect(() => {
const syncFromStorage = () => {
setShortcuts(readShortcutSettings())
}
syncFromStorage()
const onStorage = (event: StorageEvent) => {
if (event.key && event.key !== SHORTCUTS_STORAGE_KEY) return
syncFromStorage()
}
window.addEventListener("storage", onStorage)
window.addEventListener(SHORTCUTS_UPDATED_EVENT, syncFromStorage)
return () => {
window.removeEventListener("storage", onStorage)
window.removeEventListener(SHORTCUTS_UPDATED_EVENT, syncFromStorage)
}
}, [])
const updateShortcut = useCallback(
(actionId: ShortcutActionId, shortcut: string): boolean => {
const normalized = normalizeShortcut(shortcut)
if (!normalized) return false
setShortcuts((previous) => {
const next = {
...previous,
[actionId]: normalized,
}
writeShortcutSettings(next)
return next
})
return true
},
[]
)
const resetShortcuts = useCallback(() => {
setShortcuts({ ...DEFAULT_SHORTCUTS })
writeShortcutSettings(DEFAULT_SHORTCUTS)
}, [])
return {
shortcuts,
updateShortcut,
resetShortcuts,
}
}