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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
"use client"
import {
createContext,
useCallback,
useContext,
useMemo,
useReducer,
type ReactNode,
} from "react"
import type { FixActionKind } from "@/lib/types"
export type AlertLevel = "error" | "warning"
export interface AlertAction {
label: string
kind: FixActionKind
payload: string
}
export interface Alert {
id: string
level: AlertLevel
message: string
detail?: string
actions?: AlertAction[]
timestamp: number
}
interface AlertContextValue {
alerts: Alert[]
hasAlerts: boolean
pushAlert: (
level: AlertLevel,
message: string,
detail?: string,
actions?: AlertAction[]
) => string
dismissAlert: (id: string) => void
clearAll: () => void
}
type Action =
| { type: "push"; alert: Alert }
| { type: "dismiss"; id: string }
| { type: "clear_all" }
let seq = 0
const MAX_ALERTS = 50
function reducer(state: Alert[], action: Action): Alert[] {
switch (action.type) {
case "push": {
const next = [...state, action.alert]
return next.length > MAX_ALERTS ? next.slice(-MAX_ALERTS) : next
}
case "dismiss":
return state.filter((a) => a.id !== action.id)
case "clear_all":
return []
}
}
const AlertContext = createContext<AlertContextValue | null>(null)
export function useAlertContext() {
const ctx = useContext(AlertContext)
if (!ctx) {
throw new Error("useAlertContext must be used within AlertProvider")
}
return ctx
}
export function AlertProvider({ children }: { children: ReactNode }) {
const [alerts, dispatch] = useReducer(reducer, [])
const pushAlert = useCallback(
(
level: AlertLevel,
message: string,
detail?: string,
actions?: AlertAction[]
) => {
const id = `alert-${++seq}-${Date.now()}`
dispatch({
type: "push",
alert: { id, level, message, detail, actions, timestamp: Date.now() },
})
return id
},
[]
)
const dismissAlert = useCallback((id: string) => {
dispatch({ type: "dismiss", id })
}, [])
const clearAll = useCallback(() => {
dispatch({ type: "clear_all" })
}, [])
const hasAlerts = alerts.length > 0
const value = useMemo(
() => ({ alerts, hasAlerts, pushAlert, dismissAlert, clearAll }),
[alerts, hasAlerts, pushAlert, dismissAlert, clearAll]
)
return <AlertContext.Provider value={value}>{children}</AlertContext.Provider>
}

View File

@@ -0,0 +1,113 @@
"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react"
import {
loadPersistedPanelState,
savePersistedPanelState,
} from "@/lib/panel-state-storage"
export type AuxPanelTab = "file_tree" | "changes" | "git_log" | "session_files"
const DEFAULT_WIDTH = 320
const MIN_WIDTH = 200
const MAX_WIDTH = 500
const DEFAULT_IS_OPEN = false
interface AuxPanelContextValue {
isOpen: boolean
width: number
minWidth: number
maxWidth: number
activeTab: AuxPanelTab
toggle: () => void
setWidth: (w: number) => void
setActiveTab: (tab: AuxPanelTab) => void
openTab: (tab: AuxPanelTab) => void
}
const AuxPanelContext = createContext<AuxPanelContextValue | null>(null)
export function useAuxPanelContext() {
const ctx = useContext(AuxPanelContext)
if (!ctx) {
throw new Error("useAuxPanelContext must be used within AuxPanelProvider")
}
return ctx
}
function clampWidth(width: number) {
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
}
interface AuxPanelProviderProps {
children: ReactNode
folderId: number
}
export function AuxPanelProvider({
children,
folderId,
}: AuxPanelProviderProps) {
const storageKey = useMemo(
() => `folder:${folderId}:right-sidebar`,
[folderId]
)
const [isOpen, setIsOpen] = useState(DEFAULT_IS_OPEN)
const [width, setWidthState] = useState(DEFAULT_WIDTH)
const [restored, setRestored] = useState(false)
const [activeTab, setActiveTab] = useState<AuxPanelTab>("session_files")
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
const setWidth = useCallback((w: number) => {
setWidthState(clampWidth(w))
}, [])
const openTab = useCallback((tab: AuxPanelTab) => {
setActiveTab(tab)
setIsOpen(true)
}, [])
useEffect(() => {
const stored = loadPersistedPanelState(storageKey)
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsOpen(stored?.isOpen ?? DEFAULT_IS_OPEN)
setWidthState(clampWidth(stored?.width ?? DEFAULT_WIDTH))
setRestored(true)
}, [storageKey])
useEffect(() => {
if (!restored) return
savePersistedPanelState(storageKey, { isOpen, width })
}, [isOpen, restored, storageKey, width])
const value = useMemo(
() => ({
isOpen,
width,
minWidth: MIN_WIDTH,
maxWidth: MAX_WIDTH,
activeTab,
toggle,
setWidth,
setActiveTab,
openTab,
}),
[isOpen, width, activeTab, toggle, setWidth, openTab]
)
return (
<AuxPanelContext.Provider value={value}>
{children}
</AuxPanelContext.Provider>
)
}

View File

@@ -0,0 +1,269 @@
"use client"
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
useCallback,
useRef,
type ReactNode,
} from "react"
import { getFolder, listFolderConversations } from "@/lib/tauri"
import type {
AgentType,
AgentStats,
DbConversationSummary,
FolderDetail,
} from "@/lib/types"
interface SelectedConversation {
id: number
agentType: AgentType
}
interface NewConversationState {
agentType: AgentType
workingDir: string
}
interface FolderContextValue {
folder: FolderDetail | null
folderId: number
folderLoading: boolean
conversations: DbConversationSummary[]
loading: boolean
refreshing: boolean
error: string | null
selectedConversation: SelectedConversation | null
selectConversation: (id: number, agentType: AgentType) => void
clearSelection: () => void
newConversation: NewConversationState | null
startNewConversation: (agentType: AgentType, workingDir: string) => void
cancelNewConversation: () => void
stats: AgentStats | null
refreshConversations: () => void
}
const FolderContext = createContext<FolderContextValue | null>(null)
export function useFolderContext() {
const ctx = useContext(FolderContext)
if (!ctx) {
throw new Error("useFolderContext must be used within FolderProvider")
}
return ctx
}
function computeStats(conversations: DbConversationSummary[]): AgentStats {
const byAgent = new Map<AgentType, number>()
let totalMessages = 0
for (const s of conversations) {
byAgent.set(s.agent_type, (byAgent.get(s.agent_type) ?? 0) + 1)
totalMessages += s.message_count
}
return {
total_conversations: conversations.length,
total_messages: totalMessages,
by_agent: Array.from(byAgent.entries()).map(([agent_type, count]) => ({
agent_type,
conversation_count: count,
})),
}
}
/** Module-level cache — survives component unmounts / page navigations. */
const cache = new Map<string, DbConversationSummary[]>()
interface FolderProviderProps {
children: ReactNode
folderId: number
initialConversationId?: number | null
initialAgentType?: AgentType | null
}
export function FolderProvider({
children,
folderId,
initialConversationId,
initialAgentType,
}: FolderProviderProps) {
// Folder info
const [folder, setFolder] = useState<FolderDetail | null>(null)
const [folderLoading, setFolderLoading] = useState(true)
// Conversations
const cacheKey = String(folderId)
const [conversations, setConversations] = useState<DbConversationSummary[]>(
() => cache.get(cacheKey) ?? []
)
const [loading, setLoading] = useState(() => !cache.has(cacheKey))
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [selectedConversation, setSelectedConversation] =
useState<SelectedConversation | null>(() => {
if (initialConversationId != null && initialAgentType) {
return { id: initialConversationId, agentType: initialAgentType }
}
return null
})
// Sync selection when URL params change (e.g. navigation)
useEffect(() => {
if (initialConversationId != null && initialAgentType) {
setSelectedConversation({
id: initialConversationId,
agentType: initialAgentType,
})
}
}, [initialConversationId, initialAgentType])
const [newConversation, setNewConversation] =
useState<NewConversationState | null>(null)
const mountedRef = useRef(true)
// Fetch folder info
useEffect(() => {
let cancelled = false
setFolderLoading(true)
getFolder(folderId)
.then((f) => {
if (!cancelled) {
setFolder(f)
setFolderLoading(false)
}
})
.catch((err) => {
console.error("[FolderProvider] getFolder failed:", err)
if (!cancelled) setFolderLoading(false)
})
return () => {
cancelled = true
}
}, [folderId])
const fetchConversations = useCallback(async () => {
const cached = cache.get(cacheKey)
if (cached) {
setConversations(cached)
setLoading(false)
setRefreshing(true)
} else {
setLoading(true)
}
try {
setError(null)
const data = await listFolderConversations({
folder_id: folderId,
status: null,
})
if (!mountedRef.current) return
cache.set(cacheKey, data)
setConversations(data)
} catch (e) {
if (!mountedRef.current) return
if (!cached) {
setError(e instanceof Error ? e.message : String(e))
}
} finally {
if (mountedRef.current) {
setLoading(false)
setRefreshing(false)
}
}
}, [folderId, cacheKey])
useEffect(() => {
fetchConversations()
}, [fetchConversations])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const selectConversation = useCallback((id: number, agentType: AgentType) => {
setSelectedConversation({ id, agentType })
setNewConversation(null)
}, [])
const clearSelection = useCallback(() => {
setSelectedConversation(null)
}, [])
const startNewConversation = useCallback(
(agentType: AgentType, workingDir: string) => {
setNewConversation({ agentType, workingDir })
setSelectedConversation(null)
},
[]
)
const cancelNewConversation = useCallback(() => {
setNewConversation(null)
}, [])
const refreshConversations = useCallback(() => {
cache.delete(cacheKey)
fetchConversations()
}, [cacheKey, fetchConversations])
const stats = useMemo(
() => (conversations.length > 0 ? computeStats(conversations) : null),
[conversations]
)
const value = useMemo<FolderContextValue>(
() => ({
folder,
folderId,
folderLoading,
conversations,
loading,
refreshing,
error,
selectedConversation,
selectConversation,
clearSelection,
newConversation,
startNewConversation,
cancelNewConversation,
stats,
refreshConversations,
}),
[
folder,
folderId,
folderLoading,
conversations,
loading,
refreshing,
error,
selectedConversation,
selectConversation,
clearSelection,
newConversation,
startNewConversation,
cancelNewConversation,
stats,
refreshConversations,
]
)
return (
<FolderContext.Provider value={value}>{children}</FolderContext.Provider>
)
}

View File

@@ -0,0 +1,35 @@
"use client"
import { createContext, useContext, useState, useCallback } from "react"
import type { SessionStats } from "@/lib/types"
interface SessionStatsContextValue {
sessionStats: SessionStats | null
setSessionStats: (stats: SessionStats | null) => void
}
const SessionStatsContext = createContext<SessionStatsContextValue>({
sessionStats: null,
setSessionStats: () => {},
})
export function SessionStatsProvider({
children,
}: {
children: React.ReactNode
}) {
const [sessionStats, setSessionStatsRaw] = useState<SessionStats | null>(null)
const setSessionStats = useCallback(
(stats: SessionStats | null) => setSessionStatsRaw(stats),
[]
)
return (
<SessionStatsContext.Provider value={{ sessionStats, setSessionStats }}>
{children}
</SessionStatsContext.Provider>
)
}
export function useSessionStats() {
return useContext(SessionStatsContext)
}

View File

@@ -0,0 +1,94 @@
"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react"
import {
loadPersistedPanelState,
savePersistedPanelState,
} from "@/lib/panel-state-storage"
const DEFAULT_WIDTH = 320
const MIN_WIDTH = 200
const MAX_WIDTH = 600
const DEFAULT_IS_OPEN = true
interface SidebarContextValue {
isOpen: boolean
width: number
minWidth: number
maxWidth: number
toggle: () => void
setWidth: (w: number) => void
}
const SidebarContext = createContext<SidebarContextValue | null>(null)
export function useSidebarContext() {
const ctx = useContext(SidebarContext)
if (!ctx) {
throw new Error("useSidebarContext must be used within SidebarProvider")
}
return ctx
}
function clampWidth(width: number) {
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
}
interface SidebarProviderProps {
children: ReactNode
folderId: number
}
export function SidebarProvider({ children, folderId }: SidebarProviderProps) {
const storageKey = useMemo(
() => `folder:${folderId}:left-sidebar`,
[folderId]
)
const [isOpen, setIsOpen] = useState(DEFAULT_IS_OPEN)
const [width, setWidthState] = useState(DEFAULT_WIDTH)
const [restored, setRestored] = useState(false)
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
const setWidth = useCallback((w: number) => {
setWidthState(clampWidth(w))
}, [])
useEffect(() => {
const stored = loadPersistedPanelState(storageKey)
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsOpen(stored?.isOpen ?? DEFAULT_IS_OPEN)
setWidthState(clampWidth(stored?.width ?? DEFAULT_WIDTH))
setRestored(true)
}, [storageKey])
useEffect(() => {
if (!restored) return
savePersistedPanelState(storageKey, { isOpen, width })
}, [isOpen, restored, storageKey, width])
const value = useMemo(
() => ({
isOpen,
width,
minWidth: MIN_WIDTH,
maxWidth: MAX_WIDTH,
toggle,
setWidth,
}),
[isOpen, width, toggle, setWidth]
)
return (
<SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>
)
}

View File

@@ -0,0 +1,632 @@
"use client"
import {
createContext,
useContext,
useState,
useCallback,
useEffect,
useRef,
useMemo,
type ReactNode,
} from "react"
import { useFolderContext } from "@/contexts/folder-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
import { saveFolderOpenedConversations } from "@/lib/tauri"
import type {
AgentType,
ConversationStatus,
OpenedConversation,
} from "@/lib/types"
interface TabItemInternal {
id: string
kind: "conversation" | "new_conversation"
conversationId?: number
agentType: AgentType
title: string
isPinned: boolean
workingDir?: string
status?: ConversationStatus
}
export type TabItem = TabItemInternal
interface TabContextValue {
tabs: TabItem[]
activeTabId: string | null
openTab: (
conversationId: number,
agentType: AgentType,
pin?: boolean,
title?: string
) => void
closeTab: (tabId: string) => void
closeOtherTabs: (tabId: string) => void
closeAllTabs: () => void
switchTab: (tabId: string) => void
pinTab: (tabId: string) => void
openNewConversationTab: (agentType: AgentType, workingDir: string) => void
promoteNewConversationTab: (
tabId: string,
conversationId: number,
agentType: AgentType,
title: string
) => void
linkTabConversation: (
tabId: string,
conversationId: number,
agentType: AgentType,
title: string
) => void
reorderTabs: (reorderedTabs: TabItem[]) => void
}
const TabContext = createContext<TabContextValue | null>(null)
export function useTabContext() {
const ctx = useContext(TabContext)
if (!ctx) {
throw new Error("useTabContext must be used within TabProvider")
}
return ctx
}
function makeConversationTabId(
agentType: AgentType,
conversationId: number
): string {
return `conv-${agentType}-${conversationId}`
}
function makeNewConversationTabId(): string {
return `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
/**
* Find a tab that represents the given conversation, regardless of whether
* it has been promoted to a canonical id yet. Checks canonical id first,
* then falls back to matching by conversationId + agentType (covers the
* linked-but-not-yet-promoted new_conversation tabs).
*/
function findTabIndexForConversation(
tabs: TabItemInternal[],
agentType: AgentType,
conversationId: number
): number {
const canonicalId = makeConversationTabId(agentType, conversationId)
const idx = tabs.findIndex((t) => t.id === canonicalId)
if (idx >= 0) return idx
return tabs.findIndex(
(t) => t.conversationId === conversationId && t.agentType === agentType
)
}
interface TabProviderProps {
children: ReactNode
}
export function TabProvider({ children }: TabProviderProps) {
const { activateConversationPane } = useWorkspaceContext()
const {
folder,
folderId,
selectedConversation,
selectConversation,
clearSelection,
startNewConversation,
cancelNewConversation,
conversations,
} = useFolderContext()
const [rawTabs, setTabs] = useState<TabItemInternal[]>(() => {
if (selectedConversation) {
const tabId = makeConversationTabId(
selectedConversation.agentType,
selectedConversation.id
)
return [
{
id: tabId,
kind: "conversation" as const,
conversationId: selectedConversation.id,
agentType: selectedConversation.agentType,
title: "Loading...",
isPinned: true,
},
]
}
return []
})
const [activeTabId, setActiveTabId] = useState<string | null>(() => {
if (selectedConversation) {
return makeConversationTabId(
selectedConversation.agentType,
selectedConversation.id
)
}
return null
})
// Refs for volatile state — used in callbacks to avoid re-creation
const activeTabIdRef = useRef(activeTabId)
useEffect(() => {
activeTabIdRef.current = activeTabId
}, [activeTabId])
const rawTabsRef = useRef(rawTabs)
useEffect(() => {
rawTabsRef.current = rawTabs
}, [rawTabs])
const conversationsRef = useRef(conversations)
useEffect(() => {
conversationsRef.current = conversations
}, [conversations])
// Restore tabs from folder.opened_conversations when folder first loads
const [restoredFolderId, setRestoredFolderId] = useState<number | null>(() =>
selectedConversation ? folderId : null
)
useEffect(() => {
if (!folder) return
if (restoredFolderId === folder.id) return
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
setRestoredFolderId(folder.id)
const opened = folder.opened_conversations
if (opened.length === 0) return
const restoredTabs: TabItemInternal[] = opened.map((oc) => ({
id: makeConversationTabId(oc.agent_type, oc.conversation_id),
kind: "conversation" as const,
conversationId: oc.conversation_id,
agentType: oc.agent_type,
title: "Loading...",
isPinned: oc.is_pinned,
}))
setTabs(restoredTabs)
const activeItem = opened.find((oc) => oc.is_active)
const target = activeItem ?? opened[0]
setActiveTabId(
makeConversationTabId(target.agent_type, target.conversation_id)
)
})
return () => {
cancelled = true
}
}, [folder, restoredFolderId])
// Sync restored active tab to FolderProvider (deferred to avoid
// updating parent during child render)
const prevRestoredIdRef = useRef(restoredFolderId)
useEffect(() => {
if (restoredFolderId === prevRestoredIdRef.current) return
prevRestoredIdRef.current = restoredFolderId
if (!folder || folder.opened_conversations.length === 0) return
const opened = folder.opened_conversations
const target = opened.find((oc) => oc.is_active) ?? opened[0]
selectConversation(target.conversation_id, target.agent_type)
}, [restoredFolderId, folder, selectConversation])
// Debounced save to DB
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const skipSaveRef = useRef(true) // skip saving until first restore completes
useEffect(() => {
// Skip the initial render and restoration phase
if (skipSaveRef.current) {
if (restoredFolderId != null) {
skipSaveRef.current = false
}
return
}
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
saveTimerRef.current = setTimeout(() => {
const items: OpenedConversation[] = rawTabs
.filter(
(t): t is TabItemInternal & { conversationId: number } =>
t.conversationId != null
)
.map((t, i) => ({
conversation_id: t.conversationId,
agent_type: t.agentType,
position: i,
is_active: t.id === activeTabId,
is_pinned: t.isPinned,
}))
saveFolderOpenedConversations(folderId, items).catch(() => {
// Silently ignore save errors
})
}, 500)
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
}
}, [rawTabs, activeTabId, folderId, restoredFolderId])
// Pre-index conversations for O(1) lookup in tabs derivation
const conversationMap = useMemo(() => {
const m = new Map<string, (typeof conversations)[number]>()
for (const c of conversations) {
m.set(`${c.agent_type}-${c.id}`, c)
}
return m
}, [conversations])
// Derive tabs with up-to-date titles and status from conversations
const tabs = useMemo(() => {
if (conversationMap.size === 0) return rawTabs
return rawTabs.map((tab) => {
if (tab.conversationId != null) {
const conv = conversationMap.get(
`${tab.agentType}-${tab.conversationId}`
)
if (conv) {
const newTitle = conv.title || "Untitled conversation"
const newStatus = conv.status as ConversationStatus | undefined
if (tab.title !== newTitle || tab.status !== newStatus) {
return { ...tab, title: newTitle, status: newStatus }
}
}
}
return tab
})
}, [rawTabs, conversationMap])
const syncFolderContext = useCallback(
(tab: TabItem | null) => {
if (!tab) {
clearSelection()
cancelNewConversation()
return
}
if (tab.kind === "conversation" && tab.conversationId != null) {
selectConversation(tab.conversationId, tab.agentType)
} else if (tab.kind === "new_conversation" && tab.workingDir) {
startNewConversation(tab.agentType, tab.workingDir)
}
},
[
selectConversation,
clearSelection,
startNewConversation,
cancelNewConversation,
]
)
const openTab = useCallback(
(
conversationId: number,
agentType: AgentType,
pin = false,
title?: string
) => {
let activateTabId: string | undefined
setTabs((prev) => {
const existingIndex = findTabIndexForConversation(
prev,
agentType,
conversationId
)
if (existingIndex >= 0) {
activateTabId = prev[existingIndex].id
if (pin && !prev[existingIndex].isPinned) {
const updated = [...prev]
updated[existingIndex] = {
...updated[existingIndex],
isPinned: true,
}
return updated
}
return prev
}
// Resolve title from conversations list (via ref)
const resolvedTitle =
title ??
conversationsRef.current.find(
(c) => c.id === conversationId && c.agent_type === agentType
)?.title ??
"Untitled conversation"
const tabId = makeConversationTabId(agentType, conversationId)
activateTabId = tabId
const newTab: TabItemInternal = {
id: tabId,
kind: "conversation",
conversationId,
agentType,
title: resolvedTitle,
isPinned: pin,
}
if (pin) {
return [...prev, newTab]
}
// Preview (not pinned): replace existing preview tab
const previewIndex = prev.findIndex((t) => !t.isPinned)
if (previewIndex >= 0) {
const updated = [...prev]
updated[previewIndex] = newTab
return updated
}
return [...prev, newTab]
})
if (activateTabId) {
setActiveTabId(activateTabId)
}
selectConversation(conversationId, agentType)
activateConversationPane()
},
[activateConversationPane, selectConversation]
)
const makeReplacementNewConversationTab = useCallback(
(preferred?: TabItemInternal): TabItemInternal => ({
id: makeNewConversationTabId(),
kind: "new_conversation",
agentType: preferred?.agentType ?? "codex",
title: "New Conversation",
isPinned: true,
workingDir: preferred?.workingDir ?? folder?.path,
}),
[folder?.path]
)
const closeTab = useCallback(
(tabId: string) => {
let neighborToSync: TabItemInternal | undefined
setTabs((prev) => {
const index = prev.findIndex((t) => t.id === tabId)
if (index < 0) return prev
const closingTab = prev[index]
const next = prev.filter((t) => t.id !== tabId)
if (next.length === 0) {
const replacementTab = makeReplacementNewConversationTab(closingTab)
neighborToSync = replacementTab
return [replacementTab]
}
// If closing the active tab, pick a neighbor to activate
if (tabId === activeTabIdRef.current) {
// Prefer right neighbor, then left
const newIndex = Math.min(index, next.length - 1)
neighborToSync = next[newIndex]
}
return next
})
// Sync folder context outside the updater to avoid
// updating FolderProvider state during TabProvider render
if (neighborToSync) {
setActiveTabId(neighborToSync.id)
syncFolderContext(neighborToSync)
activateConversationPane()
}
},
[
activateConversationPane,
makeReplacementNewConversationTab,
syncFolderContext,
]
)
const closeOtherTabs = useCallback(
(tabId: string) => {
setTabs((prev) => {
const kept = prev.filter((t) => t.id === tabId)
return kept.length === prev.length ? prev : kept
})
const tab = rawTabsRef.current.find((t) => t.id === tabId)
if (tab) {
setActiveTabId(tabId)
syncFolderContext(tab)
}
},
[syncFolderContext]
)
const closeAllTabs = useCallback(() => {
const seedTab =
rawTabsRef.current.find(
(t) => t.kind === "new_conversation" && t.workingDir
) ??
rawTabsRef.current.find((t) => t.id === activeTabIdRef.current) ??
rawTabsRef.current[0]
const replacementTab = makeReplacementNewConversationTab(seedTab)
setTabs([replacementTab])
setActiveTabId(replacementTab.id)
syncFolderContext(replacementTab)
activateConversationPane()
}, [
activateConversationPane,
makeReplacementNewConversationTab,
syncFolderContext,
])
const switchTab = useCallback(
(tabId: string) => {
const tab = rawTabsRef.current.find((t) => t.id === tabId)
if (!tab) return
setActiveTabId(tabId)
syncFolderContext(tab)
activateConversationPane()
},
[activateConversationPane, syncFolderContext]
)
const pinTab = useCallback((tabId: string) => {
setTabs((prev) =>
prev.map((t) => (t.id === tabId ? { ...t, isPinned: true } : t))
)
}, [])
const reorderTabs = useCallback(
(reorderedTabs: TabItem[]) => setTabs(reorderedTabs),
[]
)
const openNewConversationTab = useCallback(
(agentType: AgentType, workingDir: string) => {
const existingTab = rawTabsRef.current.find(
(t) =>
t.kind === "new_conversation" &&
t.agentType === agentType &&
!t.conversationId
)
if (existingTab) {
setActiveTabId(existingTab.id)
syncFolderContext(existingTab)
activateConversationPane()
return
}
const tabId = makeNewConversationTabId()
const newTab: TabItemInternal = {
id: tabId,
kind: "new_conversation",
agentType,
title: "New Conversation",
isPinned: true,
workingDir,
}
setTabs((prev) => [...prev, newTab])
setActiveTabId(tabId)
startNewConversation(agentType, workingDir)
activateConversationPane()
},
[activateConversationPane, startNewConversation, syncFolderContext]
)
const linkTabConversation = useCallback(
(
tabId: string,
conversationId: number,
agentType: AgentType,
title: string
) => {
setTabs((prev) =>
prev.map((t) =>
t.id === tabId ? { ...t, conversationId, agentType, title } : t
)
)
},
[]
)
const promoteNewConversationTab = useCallback(
(
tabId: string,
conversationId: number,
agentType: AgentType,
title: string
) => {
let activateId: string | undefined
setTabs((prev) => {
const index = prev.findIndex((t) => t.id === tabId)
if (index < 0) return prev
const newId = makeConversationTabId(agentType, conversationId)
// Check if a *different* tab already represents this conversation
const dupeIndex = findTabIndexForConversation(
prev,
agentType,
conversationId
)
if (dupeIndex >= 0 && dupeIndex !== index) {
activateId = prev[dupeIndex].id
return prev.filter((t) => t.id !== tabId)
}
const promoted: TabItemInternal = {
...prev[index],
id: newId,
kind: "conversation",
conversationId,
agentType,
title,
isPinned: true,
}
activateId = newId
const updated = [...prev]
updated[index] = promoted
return updated
})
if (activateId) {
setActiveTabId(activateId)
selectConversation(conversationId, agentType)
activateConversationPane()
}
},
[activateConversationPane, selectConversation]
)
const value = useMemo(
() => ({
tabs,
activeTabId,
openTab,
closeTab,
closeOtherTabs,
closeAllTabs,
switchTab,
pinTab,
openNewConversationTab,
promoteNewConversationTab,
linkTabConversation,
reorderTabs,
}),
[
tabs,
activeTabId,
openTab,
closeTab,
closeOtherTabs,
closeAllTabs,
switchTab,
pinTab,
openNewConversationTab,
promoteNewConversationTab,
linkTabConversation,
reorderTabs,
]
)
return <TabContext.Provider value={value}>{children}</TabContext.Provider>
}

View File

@@ -0,0 +1,127 @@
"use client"
import {
createContext,
useCallback,
useContext,
useMemo,
useReducer,
type ReactNode,
} from "react"
export type TaskStatus = "pending" | "running" | "completed" | "failed"
export interface Task {
id: string
label: string
description?: string
status: TaskStatus
progress?: number
error?: string
}
interface TaskContextValue {
tasks: Task[]
hasRunningTasks: boolean
addTask: (id: string, label: string, description?: string) => void
updateTask: (
id: string,
update: Partial<Pick<Task, "status" | "progress" | "error">>
) => void
removeTask: (id: string) => void
clearCompleted: () => void
}
type Action =
| { type: "add"; id: string; label: string; description?: string }
| {
type: "update"
id: string
update: Partial<Pick<Task, "status" | "progress" | "error">>
}
| { type: "remove"; id: string }
| { type: "clear_completed" }
function reducer(state: Task[], action: Action): Task[] {
switch (action.type) {
case "add":
return [
...state,
{
id: action.id,
label: action.label,
description: action.description,
status: "pending",
},
]
case "update":
return state.map((t) =>
t.id === action.id ? { ...t, ...action.update } : t
)
case "remove":
return state.filter((t) => t.id !== action.id)
case "clear_completed":
return state.filter(
(t) => t.status !== "completed" && t.status !== "failed"
)
}
}
const TaskContext = createContext<TaskContextValue | null>(null)
export function useTaskContext() {
const ctx = useContext(TaskContext)
if (!ctx) {
throw new Error("useTaskContext must be used within TaskProvider")
}
return ctx
}
export function TaskProvider({ children }: { children: ReactNode }) {
const [tasks, dispatch] = useReducer(reducer, [])
const addTask = useCallback(
(id: string, label: string, description?: string) => {
dispatch({ type: "add", id, label, description })
},
[]
)
const removeTask = useCallback((id: string) => {
dispatch({ type: "remove", id })
}, [])
const updateTask = useCallback(
(
id: string,
update: Partial<Pick<Task, "status" | "progress" | "error">>
) => {
if (update.status === "completed" || update.status === "failed") {
dispatch({ type: "remove", id })
} else {
dispatch({ type: "update", id, update })
}
},
[]
)
const clearCompleted = useCallback(() => {
dispatch({ type: "clear_completed" })
}, [])
const hasRunningTasks = tasks.some((t) => t.status === "running")
const value = useMemo(
() => ({
tasks,
hasRunningTasks,
addTask,
updateTask,
removeTask,
clearCompleted,
}),
[tasks, hasRunningTasks, addTask, updateTask, removeTask, clearCompleted]
)
return <TaskContext.Provider value={value}>{children}</TaskContext.Provider>
}

View File

@@ -0,0 +1,337 @@
"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react"
import { terminalSpawn, terminalKill } from "@/lib/tauri"
import { useFolderContext } from "@/contexts/folder-context"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
export interface TerminalTab {
id: string
title: string
}
const DEFAULT_HEIGHT = 300
const MIN_HEIGHT = 150
const MAX_HEIGHT = 600
interface TerminalContextValue {
isOpen: boolean
height: number
minHeight: number
maxHeight: number
toggle: () => void
setHeight: (h: number) => void
tabs: TerminalTab[]
activeTabId: string | null
createTerminal: () => Promise<void>
createTerminalInDirectory: (
workingDir: string,
title?: string
) => Promise<string | null>
createTerminalWithCommand: (
title: string,
command: string
) => Promise<string | null>
closeTerminal: (id: string) => void
closeOtherTerminals: (id: string) => void
closeAllTerminals: () => void
renameTerminal: (id: string, title: string) => void
switchTerminal: (id: string) => void
}
const TerminalContext = createContext<TerminalContextValue | null>(null)
export function useTerminalContext() {
const ctx = useContext(TerminalContext)
if (!ctx) {
throw new Error("useTerminalContext must be used within TerminalProvider")
}
return ctx
}
export function TerminalProvider({ children }: { children: ReactNode }) {
const { folder } = useFolderContext()
const { shortcuts } = useShortcutSettings()
const [isOpen, setIsOpen] = useState(false)
const [height, setHeightState] = useState(DEFAULT_HEIGHT)
const [tabs, setTabs] = useState<TerminalTab[]>([])
const [activeTabId, setActiveTabId] = useState<string | null>(null)
const tabCounterRef = useRef(0)
const spawningRef = useRef(false)
const suppressAutoCreateRef = useRef(false)
const lastMouseActivityInTerminalRef = useRef(false)
// Keep a ref of tabs for cleanup on unmount (effect [] captures stale state)
const tabsRef = useRef(tabs)
tabsRef.current = tabs
const folderPath = folder?.path ?? ""
const killTerminalTabs = useCallback((targetTabs: TerminalTab[]) => {
targetTabs.forEach((tab) => {
terminalKill(tab.id).catch(() => {})
})
}, [])
const toggle = useCallback(() => {
setIsOpen((prev) => !prev)
}, [])
const createTerminalWithCommand = useCallback(
async (title: string, command: string) => {
if (!folderPath) return null
suppressAutoCreateRef.current = true
setIsOpen(true)
try {
const id = await terminalSpawn(folderPath, command)
tabCounterRef.current += 1
setTabs((prev) => [...prev, { id, title }])
setActiveTabId(id)
return id
} catch (err) {
console.error("Failed to spawn terminal for command:", err)
return null
} finally {
suppressAutoCreateRef.current = false
}
},
[folderPath]
)
const createTerminalInDirectory = useCallback(
async (workingDir: string, title?: string) => {
if (!workingDir || spawningRef.current) return null
suppressAutoCreateRef.current = true
setIsOpen(true)
spawningRef.current = true
try {
const id = await terminalSpawn(workingDir)
tabCounterRef.current += 1
const defaultTitle = `Terminal ${tabCounterRef.current}`
setTabs((prev) => [...prev, { id, title: title ?? defaultTitle }])
setActiveTabId(id)
return id
} catch (err) {
console.error("Failed to spawn terminal in directory:", err)
return null
} finally {
spawningRef.current = false
suppressAutoCreateRef.current = false
}
},
[]
)
const createTerminal = useCallback(async () => {
if (!folderPath) return
await createTerminalInDirectory(folderPath)
}, [folderPath, createTerminalInDirectory])
// Auto-create first terminal when panel opens with no tabs
useEffect(() => {
if (isOpen && tabs.length === 0 && !suppressAutoCreateRef.current) {
createTerminal()
}
}, [isOpen, tabs.length, createTerminal])
const setHeight = useCallback((h: number) => {
setHeightState(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, h)))
}, [])
// No stale closure — reads current activeTabId via updater
const closeTerminal = useCallback((id: string) => {
terminalKill(id).catch(() => {})
setTabs((prev) => {
const next = prev.filter((t) => t.id !== id)
if (next.length === 0) {
tabCounterRef.current = 0
setIsOpen(false)
setActiveTabId(null)
}
return next
})
setActiveTabId((prev) => (prev === id ? null : prev))
}, [])
// Auto-select last tab when active tab is removed
useEffect(() => {
if (activeTabId === null && tabs.length > 0) {
setActiveTabId(tabs[tabs.length - 1].id)
}
}, [activeTabId, tabs])
const closeOtherTerminals = useCallback(
(id: string) => {
setTabs((prev) => {
killTerminalTabs(prev.filter((t) => t.id !== id))
return prev.filter((t) => t.id === id)
})
setActiveTabId(id)
},
[killTerminalTabs]
)
const closeAllTerminals = useCallback(() => {
setTabs((prev) => {
killTerminalTabs(prev)
return []
})
tabCounterRef.current = 0
setActiveTabId(null)
setIsOpen(false)
}, [killTerminalTabs])
const renameTerminal = useCallback((id: string, title: string) => {
setTabs((prev) => prev.map((t) => (t.id === id ? { ...t, title } : t)))
}, [])
const switchTerminal = useCallback((id: string) => {
setActiveTabId(id)
}, [])
const isInTerminalRegion = useCallback((target: EventTarget | null) => {
if (!(target instanceof Element)) return false
return Boolean(target.closest('[data-terminal-panel-region="true"]'))
}, [])
const updateLastMouseActivity = useCallback(
(target: EventTarget | null) => {
const next = isInTerminalRegion(target)
if (lastMouseActivityInTerminalRef.current === next) return
lastMouseActivityInTerminalRef.current = next
},
[isInTerminalRegion]
)
useEffect(() => {
const handlePointerActivity = (event: PointerEvent) => {
updateLastMouseActivity(event.target)
}
const handleFocusActivity = (event: FocusEvent) => {
updateLastMouseActivity(event.target)
}
window.addEventListener("pointerover", handlePointerActivity, true)
window.addEventListener("pointerdown", handlePointerActivity, true)
window.addEventListener("focusin", handleFocusActivity, true)
return () => {
window.removeEventListener("pointerover", handlePointerActivity, true)
window.removeEventListener("pointerdown", handlePointerActivity, true)
window.removeEventListener("focusin", handleFocusActivity, true)
}
}, [updateLastMouseActivity])
useEffect(() => {
if (!isOpen) {
lastMouseActivityInTerminalRef.current = false
}
}, [isOpen])
useEffect(() => {
const handleTerminalHotkeys = (event: KeyboardEvent) => {
if (!isOpen) return
const targetInTerminal = isInTerminalRegion(event.target)
const activeElementInTerminal = isInTerminalRegion(document.activeElement)
const shouldHandle =
lastMouseActivityInTerminalRef.current ||
targetInTerminal ||
activeElementInTerminal
if (!shouldHandle) return
if (matchShortcutEvent(event, shortcuts.new_terminal_tab)) {
event.preventDefault()
event.stopPropagation()
void createTerminal()
return
}
if (
activeTabId &&
matchShortcutEvent(event, shortcuts.close_current_terminal_tab)
) {
event.preventDefault()
event.stopPropagation()
closeTerminal(activeTabId)
}
}
window.addEventListener("keydown", handleTerminalHotkeys, true)
return () => {
window.removeEventListener("keydown", handleTerminalHotkeys, true)
}
}, [
activeTabId,
closeTerminal,
createTerminal,
isInTerminalRegion,
isOpen,
shortcuts.close_current_terminal_tab,
shortcuts.new_terminal_tab,
])
// Cleanup all terminals on unmount — uses ref to get current tabs
useEffect(() => {
return () => {
tabsRef.current.forEach((t) => {
terminalKill(t.id).catch(() => {})
})
}
}, [])
const value = useMemo(
() => ({
isOpen,
height,
minHeight: MIN_HEIGHT,
maxHeight: MAX_HEIGHT,
toggle,
setHeight,
tabs,
activeTabId,
createTerminal,
createTerminalInDirectory,
createTerminalWithCommand,
closeTerminal,
closeOtherTerminals,
closeAllTerminals,
renameTerminal,
switchTerminal,
}),
[
isOpen,
height,
toggle,
setHeight,
tabs,
activeTabId,
createTerminal,
createTerminalInDirectory,
createTerminalWithCommand,
closeTerminal,
closeOtherTerminals,
closeAllTerminals,
renameTerminal,
switchTerminal,
]
)
return (
<TerminalContext.Provider value={value}>
{children}
</TerminalContext.Provider>
)
}

File diff suppressed because it is too large Load Diff