mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-12 02:28:05 +00:00
feat(opencode): import local session history (#1677)
* feat(cli): list local OpenCode sessions over machine RPC * feat(hub): import OpenCode sessions into HAPI * feat(web): import OpenCode history from new-session picker * fix(cli): derive OpenCode imported message ids from part ids Random ids made every scan differ, so delta classification flagged unchanged transcripts as diverged on re-import. * fix(cli): surface unreadable OpenCode db as rpc error A locked or unopenable opencode.db now fails the listing instead of reporting an empty success; summaries normalize timestamps and the web import toast counts only created or actually-appended sessions. * fix(hub): fail closed on opencode re-import past live HAPI continuation Imported entries must remain a strict prefix of the native transcript. Insertions ahead of the imported boundary or new native messages after the session continued in HAPI now fail closed instead of appending duplicated or reordered history. * fix(web,hub): apply selected launch config when importing OpenCode history Pass the model, reasoning effort, and permission mode chosen in the new-session picker through the import request and persist them on the imported HAPI session via applySessionConfig, so reopen launches with the visible selection instead of stale or default values. * fix(hub,cli): preserve explicit launch resets and skip streaming parts on import - Launch config keys are applied by presence, so explicit null/default selections from the picker must reach applySessionConfig to clear stale model, effort, or permission values on re-import. Requests that omit the fields entirely still leave persisted config untouched. - Assistant text parts are mutable while a response streams. Mirror the live scanner gate and import only finished (time.end set), synthetic, or user text so an in-progress row can never be stored and later finalized as transcript_diverged. * test(hub): fix launch-config reset assertions to satisfy strict types * fix(cli): fail opencode transcript reads on query errors instead of partial snapshots Swallowed SQLite errors could return a truncated message list that the hub later rejects as transcript_diverged with no repair path. Full transcript reads now propagate query failures; summary lastUserMessage extraction stays resilient per message. * test(cli): cover unreadable-part-table transcript read stays non-partial * fix(hub): scope launch-config failures to the per-session import result A thrown applySessionConfig call used to escape the bulk handler as an unstructured 500 even though transcripts were already persisted. Catch it per session and report it as a config_failed result so remaining sessions in a bulk request still import. Also cover the mid-transcript part-query failure with an injected opener that rejects instead of returning a holey transcript. * fix(cli): read opencode transcripts inside a deferred snapshot transaction Messages and their parts were read in separate statements, so a concurrent OpenCode write could leave a hole in the imported sequence that positional re-import checks then reject forever. Wrap each full transcript read in BEGIN DEFERRED/COMMIT with rollback on failure. * fix(cli): follow machine path policy for OpenCode imports * fix(web): use unified permission mode for OpenCode imports * fix(opencode): harden session import consistency * fix(hub): preserve metadata on rejected OpenCode imports * fix(hub): refresh OpenCode import state under lock * fix(hub): resolve OpenCode imports after locking * fix(opencode): bind imported resume to source machine
This commit is contained in:
@@ -14,6 +14,8 @@ import type {
|
||||
FileSearchResponse,
|
||||
MachinesResponse,
|
||||
MessagesResponse,
|
||||
OpencodeImportSessionsResponse,
|
||||
OpencodeLocalSessionsResponse,
|
||||
PermissionMode,
|
||||
PiImportSessionsResponse,
|
||||
PiLocalSessionsResponse,
|
||||
@@ -316,6 +318,21 @@ export class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
async getOpencodeSessions(cwd?: string | null, machineId?: string | null): Promise<OpencodeLocalSessionsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (cwd?.trim()) params.set('cwd', cwd.trim())
|
||||
if (machineId?.trim()) params.set('machineId', machineId.trim())
|
||||
const query = params.size ? `?${params.toString()}` : ''
|
||||
return await this.request<OpencodeLocalSessionsResponse>(`/api/opencode/sessions${query}`)
|
||||
}
|
||||
|
||||
async importOpencodeSessions(payload: { sessionIds: string[]; cwd?: string | null; machineId?: string | null; model?: string | null; modelReasoningEffort?: string | null; permissionMode?: PermissionMode }): Promise<OpencodeImportSessionsResponse> {
|
||||
return await this.request<OpencodeImportSessionsResponse>('/api/opencode/import-sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
async archiveCodexSession(sessionId: string, machineId?: string | null): Promise<CodexArchiveSessionResponse> {
|
||||
return await this.request<CodexArchiveSessionResponse>('/api/codex/archive-session', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OpencodeImportActions } from './OpencodeImportActions'
|
||||
|
||||
vi.mock('@/lib/use-translation', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
describe('OpencodeImportActions', () => {
|
||||
it('opens the local OpenCode history picker', () => {
|
||||
const onChooseHistory = vi.fn()
|
||||
render(
|
||||
<OpencodeImportActions
|
||||
selectedSession={null}
|
||||
isLoading={false}
|
||||
isDisabled={false}
|
||||
error={null}
|
||||
onChooseHistory={onChooseHistory}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'opencodeSync.inline.choose' }))
|
||||
expect(onChooseHistory).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { OpencodeLocalSessionSummary } from '@/types/api'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
|
||||
export function OpencodeImportActions(props: {
|
||||
selectedSession: OpencodeLocalSessionSummary | null
|
||||
isLoading: boolean
|
||||
isDisabled: boolean
|
||||
error: string | null
|
||||
onChooseHistory: () => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-[var(--app-hint)]">{t('opencodeSync.inline.title')}</div>
|
||||
<div className="truncate text-[11px] text-[var(--app-hint)]">
|
||||
{props.selectedSession ? props.selectedSession.title : t('opencodeSync.inline.description')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{props.selectedSession ? (
|
||||
<button type="button" className="text-xs text-[var(--app-link)]" onClick={props.onClear} disabled={props.isDisabled}>
|
||||
{t('opencodeSync.inline.clear')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-2 py-1.5 text-xs text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)] disabled:opacity-50"
|
||||
onClick={props.onChooseHistory}
|
||||
disabled={props.isDisabled || props.isLoading}
|
||||
>
|
||||
{props.isLoading ? t('opencodeSync.loading') : t('opencodeSync.inline.choose')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{props.error ? <div className="text-xs text-red-600">{props.error}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary, Machine, PiLocalSessionSummary } from '@/types/api'
|
||||
import type { CodexDuplicateSessionGroup, CodexLocalSessionSummary, Machine, OpencodeLocalSessionSummary, PiLocalSessionSummary } from '@/types/api'
|
||||
import type { CodexCollaborationMode, GrokPermissionMode, PermissionMode, CopilotAgentMode } from '@hapi/protocol'
|
||||
import { getClaudeEffortLevelsForModel, resolveClaudeEffortForModel } from '@hapi/protocol'
|
||||
import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode'
|
||||
@@ -52,6 +52,7 @@ import { AgentSelector } from './AgentSelector'
|
||||
import { CollaborationModeSelector } from './CollaborationModeSelector'
|
||||
import { CodexImportActions } from './CodexImportActions'
|
||||
import { PiImportActions } from './PiImportActions'
|
||||
import { OpencodeImportActions } from './OpencodeImportActions'
|
||||
import { clearBatchImportedCodexSelection, resolveCodexImportRedirectSessionId } from './codexImportMerge'
|
||||
import { AgyModelSelector } from './AgyModelSelector'
|
||||
import { DirectorySection } from './DirectorySection'
|
||||
@@ -80,6 +81,7 @@ import { PermissionField } from './PermissionField'
|
||||
import { usesNativePermissionSelect, usesSharedPermissionModeState } from '@/lib/codexFamilyPermissionAgents'
|
||||
import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog'
|
||||
import { PiSessionImportDialog } from '@/components/PiSessionImportDialog'
|
||||
import { OpencodeSessionImportDialog } from '@/components/OpencodeSessionImportDialog'
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||
import { formatRunnerSpawnError } from '../../utils/formatRunnerSpawnError'
|
||||
import { markCodexSessionsImported } from '@/lib/codexImportedSessions'
|
||||
@@ -150,6 +152,15 @@ export function NewSession(props: {
|
||||
const [isBulkImportingPiSessions, setIsBulkImportingPiSessions] = useState(false)
|
||||
const [isPiImportDialogOpen, setIsPiImportDialogOpen] = useState(false)
|
||||
const piLoadGenerationRef = useRef(0)
|
||||
const [opencodeImportSessions, setOpencodeImportSessions] = useState<OpencodeLocalSessionSummary[]>([])
|
||||
const [selectedOpencodeImportSessionId, setSelectedOpencodeImportSessionId] = useState<string | null>(null)
|
||||
const [opencodeImportMachineId, setOpencodeImportMachineId] = useState<string | null>(null)
|
||||
const [isLoadingOpencodeImportSessions, setIsLoadingOpencodeImportSessions] = useState(false)
|
||||
const [opencodeImportError, setOpencodeImportError] = useState<string | null>(null)
|
||||
const [isImportingOpencodeSession, setIsImportingOpencodeSession] = useState(false)
|
||||
const [isBulkImportingOpencodeSessions, setIsBulkImportingOpencodeSessions] = useState(false)
|
||||
const [isOpencodeImportDialogOpen, setIsOpencodeImportDialogOpen] = useState(false)
|
||||
const opencodeLoadGenerationRef = useRef(0)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const createInFlightRef = useRef(false)
|
||||
const [isBulkImportingCodexSessions, setIsBulkImportingCodexSessions] = useState(false)
|
||||
@@ -167,6 +178,8 @@ export function NewSession(props: {
|
||||
|| isBulkImportingCodexSessions
|
||||
|| isImportingPiSession
|
||||
|| isBulkImportingPiSessions
|
||||
|| isImportingOpencodeSession
|
||||
|| isBulkImportingOpencodeSessions
|
||||
)
|
||||
const worktreeInputRef = useRef<HTMLInputElement>(null)
|
||||
const preserveRestoredDraftRef = useRef(false)
|
||||
@@ -1165,6 +1178,44 @@ export function NewSession(props: {
|
||||
return message?.trim() || t('piImport.failed.body')
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
opencodeLoadGenerationRef.current += 1
|
||||
setIsLoadingOpencodeImportSessions(false)
|
||||
}, [agent, machineId, trimmedDirectory])
|
||||
|
||||
useEffect(() => () => {
|
||||
opencodeLoadGenerationRef.current += 1
|
||||
}, [])
|
||||
|
||||
const loadOpencodeImportSessions = useCallback(async () => {
|
||||
if (agent !== 'opencode' || !machineId) return
|
||||
const generation = ++opencodeLoadGenerationRef.current
|
||||
setIsLoadingOpencodeImportSessions(true)
|
||||
setOpencodeImportError(null)
|
||||
try {
|
||||
const result = await props.api.getOpencodeSessions(trimmedDirectory || null, machineId)
|
||||
if (generation !== opencodeLoadGenerationRef.current) return
|
||||
if (!result.success) throw new Error(result.error)
|
||||
setOpencodeImportSessions(result.sessions)
|
||||
setOpencodeImportMachineId(result.machineId ?? machineId)
|
||||
setSelectedOpencodeImportSessionId((current) => current && result.sessions.some((session) => session.id === current) ? current : null)
|
||||
} catch (loadError) {
|
||||
if (generation !== opencodeLoadGenerationRef.current) return
|
||||
setOpencodeImportSessions([])
|
||||
setOpencodeImportMachineId(null)
|
||||
setSelectedOpencodeImportSessionId(null)
|
||||
setOpencodeImportError(loadError instanceof Error ? loadError.message : t('opencodeSync.failed.body'))
|
||||
} finally {
|
||||
if (generation === opencodeLoadGenerationRef.current) setIsLoadingOpencodeImportSessions(false)
|
||||
}
|
||||
}, [agent, machineId, props.api, trimmedDirectory, t])
|
||||
|
||||
const formatOpencodeImportError = useCallback((code?: string, message?: string): string => {
|
||||
if (code === 'transcript_diverged') return t('opencodeSync.error.diverged')
|
||||
if (code === 'session_active') return t('opencodeSync.error.active')
|
||||
return message?.trim() || t('opencodeSync.failed.body')
|
||||
}, [t])
|
||||
|
||||
const normalizeCodexScriptError = useCallback((message: string | null | undefined, fallback: string): string => {
|
||||
const raw = (message ?? '').trim()
|
||||
if (!raw) return fallback
|
||||
@@ -1419,6 +1470,66 @@ export function NewSession(props: {
|
||||
trimmedDirectory
|
||||
])
|
||||
|
||||
const handleBulkImportOpencodeSessions = useCallback(async (sessionIds: string[]) => {
|
||||
if (isBulkImportingOpencodeSessions || isLoadingOpencodeImportSessions) return
|
||||
setIsBulkImportingOpencodeSessions(true)
|
||||
try {
|
||||
const result = await props.api.importOpencodeSessions({
|
||||
sessionIds,
|
||||
cwd: trimmedDirectory || null,
|
||||
machineId: opencodeImportMachineId ?? machineId
|
||||
})
|
||||
const importedCount = result.results.filter((item) => item.hapiSessionId && !item.error && (item.action === 'created' || (item.appended ?? 0) > 0)).length
|
||||
const failed = result.results.filter((item) => item.error)
|
||||
if (importedCount > 0) {
|
||||
addToast({
|
||||
title: t('opencodeSync.success.title'),
|
||||
body: t('opencodeSync.success.body', { n: importedCount }),
|
||||
sessionId: '',
|
||||
url: ''
|
||||
})
|
||||
await refetchSessions()
|
||||
await loadOpencodeImportSessions()
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
const first = failed[0]!.error!
|
||||
addToast({
|
||||
title: t('opencodeSync.failed.title'),
|
||||
body: t('opencodeSync.failed.partial', {
|
||||
failed: failed.length,
|
||||
reason: formatOpencodeImportError(first.code, first.message)
|
||||
}),
|
||||
sessionId: '',
|
||||
url: ''
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsOpencodeImportDialogOpen(false)
|
||||
setSelectedOpencodeImportSessionId(null)
|
||||
} catch (importError) {
|
||||
addToast({
|
||||
title: t('opencodeSync.failed.title'),
|
||||
body: importError instanceof Error ? importError.message : t('opencodeSync.failed.body'),
|
||||
sessionId: '',
|
||||
url: ''
|
||||
})
|
||||
} finally {
|
||||
setIsBulkImportingOpencodeSessions(false)
|
||||
}
|
||||
}, [
|
||||
addToast,
|
||||
formatOpencodeImportError,
|
||||
isBulkImportingOpencodeSessions,
|
||||
isLoadingOpencodeImportSessions,
|
||||
loadOpencodeImportSessions,
|
||||
machineId,
|
||||
opencodeImportMachineId,
|
||||
props.api,
|
||||
refetchSessions,
|
||||
t,
|
||||
trimmedDirectory
|
||||
])
|
||||
|
||||
const selectedCodexImportSession = useMemo(
|
||||
() => codexImportSessions.find((session) => session.id === selectedCodexImportSessionId) ?? null,
|
||||
[codexImportSessions, selectedCodexImportSessionId]
|
||||
@@ -1427,6 +1538,10 @@ export function NewSession(props: {
|
||||
() => piImportSessions.find((session) => session.id === selectedPiImportSessionId) ?? null,
|
||||
[piImportSessions, selectedPiImportSessionId]
|
||||
)
|
||||
const selectedOpencodeImportSession = useMemo(
|
||||
() => opencodeImportSessions.find((session) => session.id === selectedOpencodeImportSessionId) ?? null,
|
||||
[opencodeImportSessions, selectedOpencodeImportSessionId]
|
||||
)
|
||||
// Pi history import reopens the native session as-is; the launch-only
|
||||
// model/effort controls would silently not apply, so hide them.
|
||||
const showPiLaunchConfig = agent !== 'pi' || !selectedPiImportSession
|
||||
@@ -1447,6 +1562,9 @@ export function NewSession(props: {
|
||||
setSelectedPiImportSessionId(null)
|
||||
setPiImportSessions([])
|
||||
setPiImportMachineId(null)
|
||||
setSelectedOpencodeImportSessionId(null)
|
||||
setOpencodeImportSessions([])
|
||||
setOpencodeImportMachineId(null)
|
||||
const paths = getRecentPaths(newMachineId)
|
||||
if (paths[0]) {
|
||||
setDirectory(paths[0])
|
||||
@@ -1546,6 +1664,11 @@ export function NewSession(props: {
|
||||
if (session.cwd?.trim()) setDirectory(session.cwd.trim())
|
||||
}, [])
|
||||
|
||||
const handleSelectOpencodeImportSession = useCallback((session: OpencodeLocalSessionSummary) => {
|
||||
setSelectedOpencodeImportSessionId(session.id)
|
||||
if (session.cwd?.trim()) setDirectory(session.cwd.trim())
|
||||
}, [])
|
||||
|
||||
const handlePathClick = useCallback((path: string) => {
|
||||
setDirectory(path)
|
||||
}, [])
|
||||
@@ -1741,6 +1864,34 @@ export function NewSession(props: {
|
||||
return
|
||||
}
|
||||
|
||||
if (agent === 'opencode' && selectedOpencodeImportSession) {
|
||||
setIsImportingOpencodeSession(true)
|
||||
const result = await props.api.importOpencodeSessions({
|
||||
sessionIds: [selectedOpencodeImportSession.id],
|
||||
cwd: selectedOpencodeImportSession.cwd ?? trimmedDirectory,
|
||||
machineId: opencodeImportMachineId ?? machineId,
|
||||
model: resolvedModel ?? null,
|
||||
modelReasoningEffort: resolvedModelReasoningEffort ?? null,
|
||||
permissionMode: nativePermissionMode
|
||||
})
|
||||
const imported = result.results.find((item) => item.opencodeSessionId === selectedOpencodeImportSession.id)
|
||||
if (imported?.error) {
|
||||
setIsImportingOpencodeSession(false)
|
||||
haptic.notification('error')
|
||||
setError(formatOpencodeImportError(imported.error.code, imported.error.message))
|
||||
return
|
||||
}
|
||||
if (!imported?.hapiSessionId) throw new Error(result.error || t('opencodeSync.failed.body'))
|
||||
const reopened = await props.api.reopenSession(imported.hapiSessionId)
|
||||
haptic.notification('success')
|
||||
savePreferredLaunchSettings(machineId, agent, preferredLaunchSettings)
|
||||
clearNewSessionFormDraft()
|
||||
setLastUsedMachineId(machineId)
|
||||
addRecentPath(machineId, trimmedDirectory)
|
||||
props.onSuccess(reopened.sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await spawnSession({
|
||||
machineId,
|
||||
directory: trimmedDirectory,
|
||||
@@ -1777,6 +1928,7 @@ export function NewSession(props: {
|
||||
} catch (e) {
|
||||
setIsImportingCodexSession(false)
|
||||
setIsImportingPiSession(false)
|
||||
setIsImportingOpencodeSession(false)
|
||||
haptic.notification('error')
|
||||
setError(e instanceof Error ? e.message : 'Failed to create session')
|
||||
} finally {
|
||||
@@ -1935,6 +2087,19 @@ export function NewSession(props: {
|
||||
onClear={() => setSelectedPiImportSessionId(null)}
|
||||
/>
|
||||
) : null}
|
||||
{agent === 'opencode' ? (
|
||||
<OpencodeImportActions
|
||||
selectedSession={selectedOpencodeImportSession}
|
||||
isLoading={isLoadingOpencodeImportSessions}
|
||||
isDisabled={isFormDisabled}
|
||||
error={opencodeImportError}
|
||||
onChooseHistory={() => {
|
||||
setIsOpencodeImportDialogOpen(true)
|
||||
void loadOpencodeImportSessions()
|
||||
}}
|
||||
onClear={() => setSelectedOpencodeImportSessionId(null)}
|
||||
/>
|
||||
) : null}
|
||||
{agent === 'dsh' ? (
|
||||
<ModelSelector
|
||||
agent={agent}
|
||||
@@ -2123,7 +2288,7 @@ export function NewSession(props: {
|
||||
) : null}
|
||||
|
||||
<ActionButtons
|
||||
isPending={isCreating || isPending || isImportingCodexSession || isImportingPiSession}
|
||||
isPending={isCreating || isPending || isImportingCodexSession || isImportingPiSession || isImportingOpencodeSession}
|
||||
canCreate={canCreate}
|
||||
isDisabled={isFormDisabled}
|
||||
createLabel={createLabel}
|
||||
@@ -2174,6 +2339,26 @@ export function NewSession(props: {
|
||||
isPending={isBulkImportingPiSessions}
|
||||
isLoading={isLoadingPiImportSessions}
|
||||
/>
|
||||
<OpencodeSessionImportDialog
|
||||
isOpen={isOpencodeImportDialogOpen}
|
||||
onClose={() => setIsOpencodeImportDialogOpen(false)}
|
||||
sessions={opencodeImportSessions}
|
||||
currentSessionId={selectedOpencodeImportSessionId}
|
||||
currentWorkDirectory={trimmedDirectory}
|
||||
onConfirm={async (sessionIds) => {
|
||||
if (sessionIds.length === 1) {
|
||||
const session = opencodeImportSessions.find((candidate) => candidate.id === sessionIds[0])
|
||||
if (session) {
|
||||
handleSelectOpencodeImportSession(session)
|
||||
setIsOpencodeImportDialogOpen(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
await handleBulkImportOpencodeSessions(sessionIds)
|
||||
}}
|
||||
isPending={isBulkImportingOpencodeSessions}
|
||||
isLoading={isLoadingOpencodeImportSessions}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={isDuplicateMergeConfirmOpen && duplicateSessionGroups.length > 0}
|
||||
onClose={closeDuplicateMergeDialog}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { OpencodeLocalSessionSummary } from '@/types/api'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SelectControl } from '@/components/ui/select-control'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
|
||||
const ALL_DIRECTORIES = '__all__'
|
||||
|
||||
export function OpencodeSessionImportDialog(props: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
sessions: OpencodeLocalSessionSummary[]
|
||||
currentSessionId: string | null
|
||||
currentWorkDirectory?: string | null
|
||||
onConfirm: (sessionIds: string[]) => Promise<void>
|
||||
isPending: boolean
|
||||
isLoading: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [directory, setDirectory] = useState(ALL_DIRECTORIES)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const directories = useMemo(() => Array.from(new Set(props.sessions
|
||||
.map((session) => session.cwd?.trim())
|
||||
.filter((value): value is string => Boolean(value)))).sort(), [props.sessions])
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
return props.sessions.filter((session) => {
|
||||
if (directory !== ALL_DIRECTORIES && session.cwd !== directory) return false
|
||||
if (!normalized) return true
|
||||
return [session.title, session.lastUserMessage, session.cwd, session.id]
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.some((value) => value.toLowerCase().includes(normalized))
|
||||
})
|
||||
}, [directory, props.sessions, query])
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.isOpen) return
|
||||
setSelectedIds(props.currentSessionId ? [props.currentSessionId] : [])
|
||||
const cwd = props.currentWorkDirectory?.trim()
|
||||
setDirectory(cwd && directories.includes(cwd) ? cwd : ALL_DIRECTORIES)
|
||||
setQuery('')
|
||||
}, [directories, props.currentSessionId, props.currentWorkDirectory, props.isOpen])
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (props.isPending || props.isLoading) return
|
||||
setSelectedIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id])
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle>{t('opencodeSync.dialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('opencodeSync.dialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
{t('opencodeSync.dialog.concurrentWarning')}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-[var(--app-hint)]">
|
||||
<span>{t('opencodeSync.selectedCount', { n: selectedIds.length })}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" disabled={selectedIds.length === 0 || props.isPending} onClick={() => setSelectedIds([])}>
|
||||
{t('opencodeSync.clearAll')}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" disabled={filtered.length === 0 || props.isPending} onClick={() => setSelectedIds(filtered.map((session) => session.id))}>
|
||||
{t('opencodeSync.selectAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{props.sessions.length > 0 ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<SelectControl value={directory} disabled={props.isPending} onChange={(event) => setDirectory(event.target.value)}>
|
||||
<option value={ALL_DIRECTORIES}>{t('opencodeSync.allDirectories')}</option>
|
||||
{directories.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</SelectControl>
|
||||
<input
|
||||
type="search"
|
||||
className="h-9 rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 text-sm text-[var(--app-fg)]"
|
||||
value={query}
|
||||
disabled={props.isPending}
|
||||
placeholder={t('opencodeSync.search')}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-[var(--app-border)]">
|
||||
{props.isLoading ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">{t('opencodeSync.loading')}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--app-hint)]">{t('opencodeSync.empty')}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--app-border)]">
|
||||
{filtered.map((session) => {
|
||||
const checked = selectedIds.includes(session.id)
|
||||
return (
|
||||
<label key={session.id} className="flex cursor-pointer items-start gap-3 px-3 py-2 hover:bg-[var(--app-subtle-bg)]">
|
||||
<input type="checkbox" className="mt-1 h-4 w-4 accent-[var(--app-link)]" checked={checked} onChange={() => toggle(session.id)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-[var(--app-fg)]">
|
||||
<span className="truncate">{session.title}</span>
|
||||
{session.hapiSessionId ? <span className="rounded bg-[var(--app-subtle-bg)] px-1.5 py-0.5 text-[10px] text-[var(--app-hint)]">{t('opencodeSync.imported')}</span> : null}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-[var(--app-hint)]">{session.lastUserMessage ?? session.cwd ?? session.id}</span>
|
||||
<span className="mt-0.5 block text-[10px] text-[var(--app-hint)]">{new Date(session.modifiedAt).toLocaleString()}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={props.onClose} disabled={props.isPending}>{t('button.cancel')}</Button>
|
||||
<Button type="button" onClick={() => void props.onConfirm(selectedIds)} disabled={selectedIds.length === 0 || props.isPending || props.isLoading}>
|
||||
{props.isPending ? t('opencodeSync.importing') : t('opencodeSync.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -134,6 +134,30 @@ export default {
|
||||
'piImport.manual.success.body': 'Synced {n} new Pi history items.',
|
||||
'piImport.manual.success.noNewMessages': 'Pi history is already up to date.',
|
||||
'piImport.manual.failed.title': 'Pi history sync failed',
|
||||
'opencodeSync.inline.title': 'Import OpenCode history',
|
||||
'opencodeSync.inline.description': 'Optional: choose an existing local OpenCode session to import and continue.',
|
||||
'opencodeSync.inline.clear': 'Clear',
|
||||
'opencodeSync.inline.choose': 'Choose history',
|
||||
'opencodeSync.dialog.title': 'Import OpenCode sessions',
|
||||
'opencodeSync.dialog.description': 'Select local OpenCode histories. One selection is continued now; multiple selections are imported in the background.',
|
||||
'opencodeSync.dialog.concurrentWarning': 'Multiple processes can open the same OpenCode session, but avoid writing from stale copies. Before continuing in HAPI, reload or close any other OpenCode process to avoid an unintended branch.',
|
||||
'opencodeSync.loading': 'Scanning OpenCode sessions…',
|
||||
'opencodeSync.empty': 'No importable OpenCode sessions found.',
|
||||
'opencodeSync.selectedCount': '{n} selected',
|
||||
'opencodeSync.clearAll': 'Clear all',
|
||||
'opencodeSync.selectAll': 'Select all',
|
||||
'opencodeSync.allDirectories': 'All directories',
|
||||
'opencodeSync.search': 'Search title, prompt, path, or ID',
|
||||
'opencodeSync.imported': 'Imported',
|
||||
'opencodeSync.importing': 'Importing…',
|
||||
'opencodeSync.confirm': 'Import',
|
||||
'opencodeSync.success.title': 'OpenCode sessions imported',
|
||||
'opencodeSync.success.body': 'Imported {n} OpenCode sessions.',
|
||||
'opencodeSync.failed.title': 'OpenCode import failed',
|
||||
'opencodeSync.failed.body': 'Failed to import OpenCode history.',
|
||||
'opencodeSync.failed.partial': '{failed} sessions failed: {reason}',
|
||||
'opencodeSync.error.diverged': 'The local OpenCode branch changed after the previous import. Review the session before continuing.',
|
||||
'opencodeSync.error.active': 'Stop the active Hapi OpenCode session before importing newer native history.',
|
||||
// 中文注释:以下文案支撑导入弹窗的多选列表、加载态,以及右上角重启 Codex 客户端按钮提示。
|
||||
'codexSync.confirm.selectAll': 'Select all',
|
||||
'codexSync.confirm.clearAll': 'Clear all',
|
||||
|
||||
@@ -134,6 +134,30 @@ export default {
|
||||
'piImport.manual.success.body': '已同步 {n} 条新的 Pi 历史记录。',
|
||||
'piImport.manual.success.noNewMessages': 'Pi 历史已经是最新状态。',
|
||||
'piImport.manual.failed.title': 'Pi 历史同步失败',
|
||||
'opencodeSync.inline.title': '导入 OpenCode 历史',
|
||||
'opencodeSync.inline.description': '可选:选择本机已有的 OpenCode session,导入历史后继续对话。',
|
||||
'opencodeSync.inline.clear': '清除',
|
||||
'opencodeSync.inline.choose': '选择历史',
|
||||
'opencodeSync.dialog.title': '导入 OpenCode sessions',
|
||||
'opencodeSync.dialog.description': '选择本机 OpenCode 历史。单选会立即继续,多选会批量导入到会话列表。',
|
||||
'opencodeSync.dialog.concurrentWarning': '多个进程可以同时打开同一个 OpenCode session,但不要从未刷新的副本交替写入。继续在 HAPI 中操作前,请重新加载或关闭其他 OpenCode 进程,避免产生意外分支。',
|
||||
'opencodeSync.loading': '正在扫描 OpenCode sessions…',
|
||||
'opencodeSync.empty': '没有找到可导入的 OpenCode session。',
|
||||
'opencodeSync.selectedCount': '已选择 {n} 个',
|
||||
'opencodeSync.clearAll': '全部清除',
|
||||
'opencodeSync.selectAll': '全部选择',
|
||||
'opencodeSync.allDirectories': '全部目录',
|
||||
'opencodeSync.search': '搜索标题、消息、路径或 ID',
|
||||
'opencodeSync.imported': '已导入',
|
||||
'opencodeSync.importing': '正在导入…',
|
||||
'opencodeSync.confirm': '导入',
|
||||
'opencodeSync.success.title': 'OpenCode sessions 已导入',
|
||||
'opencodeSync.success.body': '已导入 {n} 个 OpenCode sessions。',
|
||||
'opencodeSync.failed.title': 'OpenCode 导入失败',
|
||||
'opencodeSync.failed.body': '导入 OpenCode 历史失败。',
|
||||
'opencodeSync.failed.partial': '{failed} 个 session 导入失败:{reason}',
|
||||
'opencodeSync.error.diverged': '本机 OpenCode 分支在上次导入后发生变化,请先检查会话再继续。',
|
||||
'opencodeSync.error.active': '请先停止正在运行的 Hapi OpenCode session,再导入新增的原生历史。',
|
||||
// 中文注释:以下文案支撑导入弹窗的多选列表、加载态,以及右上角重启 Codex 客户端按钮提示。
|
||||
'codexSync.confirm.selectAll': '全选',
|
||||
'codexSync.confirm.clearAll': '全取消',
|
||||
|
||||
@@ -290,6 +290,42 @@ export type PiImportSessionsResponse = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type OpencodeLocalSessionSummary = {
|
||||
id: string
|
||||
title: string
|
||||
lastUserMessage?: string | null
|
||||
cwd?: string | null
|
||||
file: string
|
||||
modifiedAt: number
|
||||
hapiSessionId?: string
|
||||
}
|
||||
|
||||
export type OpencodeLocalSessionsResponse = {
|
||||
success: true
|
||||
sessions: OpencodeLocalSessionSummary[]
|
||||
machineId: string
|
||||
} | {
|
||||
success: false
|
||||
error: string
|
||||
sessions: []
|
||||
machineId?: string
|
||||
}
|
||||
|
||||
export type OpencodeImportResult = {
|
||||
opencodeSessionId: string
|
||||
hapiSessionId?: string
|
||||
action?: 'created' | 'updated' | 'unchanged'
|
||||
appended?: number
|
||||
error?: { code: string; message: string }
|
||||
}
|
||||
|
||||
export type OpencodeImportSessionsResponse = {
|
||||
success: boolean
|
||||
results: OpencodeImportResult[]
|
||||
machineId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
|
||||
export type CodexArchiveSessionResponse = {
|
||||
success: true
|
||||
|
||||
Reference in New Issue
Block a user