Initial commit
This commit is contained in:
362
src/hooks/use-connection-lifecycle.ts
Normal file
362
src/hooks/use-connection-lifecycle.ts
Normal 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
158
src/hooks/use-connection.ts
Normal 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,
|
||||
]
|
||||
)
|
||||
}
|
||||
162
src/hooks/use-db-message-detail.ts
Normal file
162
src/hooks/use-db-message-detail.ts
Normal 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
8
src/hooks/use-is-mac.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { usePlatform } from "./use-platform"
|
||||
|
||||
export function useIsMac(): boolean {
|
||||
const { isMac } = usePlatform()
|
||||
return isMac
|
||||
}
|
||||
60
src/hooks/use-message-detail.ts
Normal file
60
src/hooks/use-message-detail.ts
Normal 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
51
src/hooks/use-platform.ts
Normal 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",
|
||||
}
|
||||
}
|
||||
75
src/hooks/use-shortcut-settings.ts
Normal file
75
src/hooks/use-shortcut-settings.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user