feat: assistant workspace V2 — indexing, retrieval, security hardening & workspace switching
Assistant Workspace V2: - Markdown indexing engine (incremental manifest + chunks JSONL) - Keyword retrieval with CJK bigram support and hotset boosting - Directory taxonomy inference and evolution suggestions - Workspace config management (.assistant/config.json) - Organize API: capture, classify, move, archive, suggest-evolution Workspace switching: - Inspect API (GET /api/workspace/inspect) for pre-switch validation - Two-phase switching with confirmation dialogs (5 workspace statuses) - Atomic save: init/reset runs before setSetting(), rollback on failure - Auto-navigate to new/reused session after switch - Mismatch banner in ChatView (only for former assistant sessions) - Debounced path validation with visual indicators Security hardening: - Completion fence (onboarding-complete/checkin-complete) only processes when session.workingDirectory === assistant_workspace_path (frontend) AND session.working_directory === workspacePath (backend double-check) - assertContained() in workspace-organizer: rejects absolute paths, ~/, ../ traversal, and symlink escapes for captureNote/moveFile - organize API validates all path params at entry (validateRelativePath) - captureDefault sanitized to relative path on onboarding write - R_OK + W_OK validation on GET settings, session creation, and PUT Bug fixes: - hookTriggeredSessionId now read before auto-trigger; allows retry if session has no messages (previous trigger failed silently) - workspace-switched event listener matches oldPath (not just != newPath) so normal project chats never show the mismatch banner - Daily check-in card hidden when onboarding not complete - Refresh docs now calls generateRootDocs() + generateDirectoryDocs() - Banner "Open" button reuses latest session (checkin mode) instead of always creating new onboarding session Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
19aba25c8f
commit
94fdd36d27
28 files changed
+3855
-181
No files matched your search
@@ -7,6 +7,7 @@ import type {
|
||||
HighlighterGeneric,
|
||||
ThemedToken,
|
||||
} from "shiki";
|
||||
import { bundledLanguages } from "shiki";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -32,10 +33,10 @@ import { createHighlighter } from "shiki";
|
||||
|
||||
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
|
||||
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
|
||||
// eslint-disable-next-line no-bitwise -- shiki bitflag check
|
||||
|
||||
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
|
||||
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
|
||||
// eslint-disable-next-line no-bitwise -- shiki bitflag check
|
||||
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
|
||||
const isUnderline = (fontStyle: number | undefined) =>
|
||||
@@ -138,24 +139,30 @@ const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
||||
return `${language}:${code.length}:${start}:${end}`;
|
||||
};
|
||||
|
||||
const isBundledLanguage = (lang: string): lang is BundledLanguage =>
|
||||
lang in bundledLanguages || lang === "text" || lang === "plaintext";
|
||||
|
||||
const getHighlighter = (
|
||||
language: BundledLanguage
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
||||
const cached = highlighterCache.get(language);
|
||||
// Normalize unknown languages to "text" before hitting Shiki
|
||||
const safeLang = isBundledLanguage(language) ? language : ("text" as BundledLanguage);
|
||||
|
||||
const cached = highlighterCache.get(safeLang);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const highlighterPromise = createHighlighter({
|
||||
langs: [language],
|
||||
langs: [safeLang],
|
||||
themes: ["github-light", "github-dark"],
|
||||
}).catch(() => {
|
||||
// Language not supported by Shiki — fall back to plain text
|
||||
highlighterCache.delete(language);
|
||||
highlighterCache.delete(safeLang);
|
||||
return getHighlighter("text" as BundledLanguage);
|
||||
});
|
||||
|
||||
highlighterCache.set(language, highlighterPromise);
|
||||
highlighterCache.set(safeLang, highlighterPromise);
|
||||
return highlighterPromise;
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { createCodePlugin } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
@@ -322,7 +322,18 @@ export const MessageBranchPage = ({
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
// Wrap @streamdown/code to silently skip unsupported languages instead of throwing
|
||||
const _codePlugin = createCodePlugin();
|
||||
const safeCode: typeof _codePlugin = {
|
||||
..._codePlugin,
|
||||
highlight(params, callback) {
|
||||
if (!_codePlugin.supportsLanguage(params.language)) {
|
||||
return null; // Let Streamdown render as plain text
|
||||
}
|
||||
return _codePlugin.highlight(params, callback);
|
||||
},
|
||||
};
|
||||
const streamdownPlugins = { cjk, code: safeCode, math, mermaid };
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot } from '@/types';
|
||||
import { MessageList } from './MessageList';
|
||||
import { MessageInput } from './MessageInput';
|
||||
import { usePanel } from '@/hooks/usePanel';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { BatchExecutionDashboard, BatchContextSync } from './batch-image-gen';
|
||||
import { setLastGeneratedImages, transferPendingToMessage } from '@/lib/image-ref-store';
|
||||
import {
|
||||
@@ -27,7 +29,12 @@ interface ChatViewProps {
|
||||
|
||||
export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode, providerId }: ChatViewProps) {
|
||||
const { setStreamingSessionId, workingDirectory, setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
||||
|
||||
// Workspace mismatch banner state
|
||||
const [workspaceMismatchPath, setWorkspaceMismatchPath] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(initialHasMore);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const loadingMoreRef = useRef(false);
|
||||
@@ -203,6 +210,11 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
const state = data.state;
|
||||
if (!state) return;
|
||||
|
||||
// Check hookTriggeredSessionId: if this session already has a trigger in progress
|
||||
// AND there are existing messages (conversation started), skip to avoid re-triggering.
|
||||
// If the session has no messages, the previous trigger may have failed — allow retry.
|
||||
if (state.hookTriggeredSessionId === sessionId && initialMessages.length > 0) return;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const needsOnboarding = !state.onboardingComplete;
|
||||
const needsCheckIn = state.onboardingComplete && state.lastCheckInDate !== today;
|
||||
@@ -259,6 +271,73 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkAssistantTrigger]);
|
||||
|
||||
// Detect workspace mismatch: only show banner when this session was previously
|
||||
// an assistant workspace session (has .assistant data) but the path has since changed.
|
||||
// Normal project chats should never see this banner.
|
||||
useEffect(() => {
|
||||
if (!workingDirectory) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings/workspace');
|
||||
if (!res.ok || cancelled) return;
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
|
||||
if (data.path && workingDirectory !== data.path) {
|
||||
// Only show banner if this session's workingDirectory was itself an assistant workspace
|
||||
// (i.e., it has .assistant/state.json). Regular project chats skip the banner.
|
||||
const inspectRes = await fetch(`/api/workspace/inspect?path=${encodeURIComponent(workingDirectory)}`);
|
||||
if (!inspectRes.ok || cancelled) return;
|
||||
const inspectData = await inspectRes.json();
|
||||
if (inspectData.hasAssistantData) {
|
||||
setWorkspaceMismatchPath(data.path);
|
||||
} else {
|
||||
setWorkspaceMismatchPath(null);
|
||||
}
|
||||
} else {
|
||||
setWorkspaceMismatchPath(null);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [workingDirectory]);
|
||||
|
||||
// Listen for workspace-switched events (from AssistantWorkspaceSection).
|
||||
// Only show banner if this session's workingDirectory was the OLD assistant workspace.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.newPath && workingDirectory && workingDirectory === detail.oldPath) {
|
||||
setWorkspaceMismatchPath(detail.newPath);
|
||||
}
|
||||
};
|
||||
window.addEventListener('assistant-workspace-switched', handler);
|
||||
return () => window.removeEventListener('assistant-workspace-switched', handler);
|
||||
}, [workingDirectory]);
|
||||
|
||||
const handleOpenNewAssistant = useCallback(async () => {
|
||||
try {
|
||||
const model = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') || '' : '';
|
||||
const provider_id = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') || '' : '';
|
||||
// Prefer reusing the latest assistant session (checkin mode) rather than always creating a new onboarding session
|
||||
const res = await fetch('/api/workspace/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'checkin', model, provider_id }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
window.dispatchEvent(new CustomEvent('session-created'));
|
||||
router.push(`/chat/${data.session.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Failed to open assistant session:', e);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const loadEarlierMessages = useCallback(async () => {
|
||||
// Use ref as atomic lock to prevent double-fetch from rapid clicks
|
||||
if (loadingMoreRef.current || !hasMore || messages.length === 0) return;
|
||||
@@ -296,8 +375,20 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
[sessionId, setPendingApprovalSessionId]
|
||||
);
|
||||
|
||||
// Detect assistant project completion signals in final message content
|
||||
// Detect assistant project completion signals in final message content.
|
||||
// Only processes fences when this session's workingDirectory matches the current assistant workspace path.
|
||||
const detectAssistantCompletion = useCallback(async (content: string) => {
|
||||
// Guard: only allow fence processing for the current assistant workspace session
|
||||
if (!workingDirectory) return;
|
||||
try {
|
||||
const wsRes = await fetch('/api/settings/workspace');
|
||||
if (!wsRes.ok) return;
|
||||
const wsData = await wsRes.json();
|
||||
if (!wsData.path || wsData.path !== workingDirectory) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearHookTriggered = () =>
|
||||
fetch('/api/workspace/hook-triggered', {
|
||||
method: 'POST',
|
||||
@@ -313,7 +404,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
await fetch('/api/workspace/onboarding', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers }),
|
||||
body: JSON.stringify({ answers, sessionId }),
|
||||
});
|
||||
await clearHookTriggered();
|
||||
} catch (e) {
|
||||
@@ -330,14 +421,14 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
await fetch('/api/workspace/checkin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers }),
|
||||
body: JSON.stringify({ answers, sessionId }),
|
||||
});
|
||||
await clearHookTriggered();
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Check-in completion failed:', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [sessionId, workingDirectory]);
|
||||
|
||||
// Send message — delegates stream management to the manager
|
||||
const sendMessage = useCallback(
|
||||
@@ -508,6 +599,20 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Workspace mismatch banner */}
|
||||
{workspaceMismatchPath && (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-yellow-500/30 bg-yellow-500/10 px-4 py-2">
|
||||
<span className="text-xs text-yellow-700 dark:text-yellow-400">
|
||||
{t('assistant.switchedBanner', { path: workspaceMismatchPath })}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleOpenNewAssistant}
|
||||
className="shrink-0 rounded-md bg-yellow-600 px-3 py-1 text-xs font-medium text-white hover:bg-yellow-700 transition-colors"
|
||||
>
|
||||
{t('assistant.openNewAssistant')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamingContent={streamingContent}
|
||||
|
||||
@@ -567,7 +567,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
|
||||
{group.displayName}
|
||||
</span>
|
||||
{workspacePath && group.workingDirectory === workspacePath && (
|
||||
<HugeiconsIcon icon={AiUserIcon} className="h-3.5 w-3.5 shrink-0 text-violet-500" />
|
||||
<HugeiconsIcon icon={AiUserIcon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{/* New chat in project button (on hover) */}
|
||||
{group.workingDirectory !== "" && (
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Loading02Icon } from "@hugeicons/core-free-icons";
|
||||
import { Loading02Icon, CheckmarkCircle02Icon, Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import type { WorkspaceInspectResult } from "@/types";
|
||||
|
||||
interface FileStatus {
|
||||
exists: boolean;
|
||||
@@ -19,8 +30,26 @@ interface WorkspaceState {
|
||||
schemaVersion: number;
|
||||
}
|
||||
|
||||
interface TaxonomyCategoryInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
role: string;
|
||||
confidence: number;
|
||||
source: string;
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
interface IndexStats {
|
||||
fileCount: number;
|
||||
chunkCount: number;
|
||||
lastIndexed: number;
|
||||
staleCount: number;
|
||||
}
|
||||
|
||||
interface WorkspaceInfo {
|
||||
path: string | null;
|
||||
valid?: boolean;
|
||||
reason?: string;
|
||||
exists?: boolean;
|
||||
files: Record<string, FileStatus>;
|
||||
state: WorkspaceState | null;
|
||||
@@ -33,6 +62,17 @@ const FILE_LABELS: Record<string, string> = {
|
||||
memory: "memory.md",
|
||||
};
|
||||
|
||||
type TabId = 'files' | 'taxonomy' | 'index' | 'organize';
|
||||
|
||||
type PathValidationStatus = 'idle' | 'checking' | 'valid' | 'invalid';
|
||||
|
||||
type ConfirmDialogType =
|
||||
| { kind: 'not_found' }
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'normal_directory' }
|
||||
| { kind: 'existing_workspace'; summary: NonNullable<WorkspaceInspectResult['summary']> }
|
||||
| { kind: 'partial_workspace' };
|
||||
|
||||
export function AssistantWorkspaceSection() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -42,6 +82,20 @@ export function AssistantWorkspaceSection() {
|
||||
const [refreshingDocs, setRefreshingDocs] = useState(false);
|
||||
const [pathInput, setPathInput] = useState("");
|
||||
const [creatingSession, setCreatingSession] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabId>('files');
|
||||
const [taxonomy, setTaxonomy] = useState<TaxonomyCategoryInfo[]>([]);
|
||||
const [indexStats, setIndexStats] = useState<IndexStats | null>(null);
|
||||
const [reindexing, setReindexing] = useState(false);
|
||||
const [archiving, setArchiving] = useState(false);
|
||||
|
||||
// Path validation state
|
||||
const [pathValidation, setPathValidation] = useState<PathValidationStatus>('idle');
|
||||
const [pathError, setPathError] = useState<string | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Confirmation dialog state
|
||||
const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogType | null>(null);
|
||||
const [inspecting, setInspecting] = useState(false);
|
||||
|
||||
const fetchWorkspace = useCallback(async () => {
|
||||
try {
|
||||
@@ -58,28 +112,211 @@ export function AssistantWorkspaceSection() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchTaxonomy = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings/workspace");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.taxonomy) setTaxonomy(data.taxonomy);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const fetchIndexStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/workspace/index");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setIndexStats(data);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkspace();
|
||||
}, [fetchWorkspace]);
|
||||
|
||||
const handleSavePath = useCallback(async (initialize: boolean) => {
|
||||
useEffect(() => {
|
||||
if (workspace?.path && activeTab === 'taxonomy') fetchTaxonomy();
|
||||
if (workspace?.path && activeTab === 'index') fetchIndexStats();
|
||||
}, [workspace?.path, activeTab, fetchTaxonomy, fetchIndexStats]);
|
||||
|
||||
// Debounced path validation
|
||||
const validatePath = useCallback((path: string) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
setPathError(null);
|
||||
|
||||
if (!path.trim()) {
|
||||
setPathValidation('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
setPathValidation('checking');
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/workspace/inspect?path=${encodeURIComponent(path.trim())}`);
|
||||
if (!res.ok) {
|
||||
setPathValidation('invalid');
|
||||
setPathError(t('assistant.inspectFailed'));
|
||||
return;
|
||||
}
|
||||
const data: WorkspaceInspectResult = await res.json();
|
||||
if (!data.exists) {
|
||||
// Non-existent path is allowed — user can create it via initialize
|
||||
setPathValidation('valid');
|
||||
} else if (!data.isDirectory) {
|
||||
setPathValidation('invalid');
|
||||
setPathError(t('assistant.pathNotDirectory'));
|
||||
} else if (!data.readable) {
|
||||
setPathValidation('invalid');
|
||||
setPathError(t('assistant.pathNotReadable'));
|
||||
} else if (!data.writable) {
|
||||
setPathValidation('invalid');
|
||||
setPathError(t('assistant.pathNotWritable'));
|
||||
} else {
|
||||
setPathValidation('valid');
|
||||
}
|
||||
} catch {
|
||||
setPathValidation('invalid');
|
||||
setPathError(t('assistant.inspectFailed'));
|
||||
}
|
||||
}, 500);
|
||||
}, [t]);
|
||||
|
||||
// Clean up debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePathInputChange = useCallback((value: string) => {
|
||||
setPathInput(value);
|
||||
validatePath(value);
|
||||
}, [validatePath]);
|
||||
|
||||
// Execute the actual save + optional auto-navigate
|
||||
// navigateMode: 'new' = always create new session, 'reuse' = try to reuse latest session
|
||||
const executeSave = useCallback(async (initialize: boolean, resetOnboarding?: boolean, navigateMode: 'new' | 'reuse' = 'new') => {
|
||||
if (!pathInput.trim()) return;
|
||||
const oldPath = workspace?.path || null;
|
||||
const newPath = pathInput.trim();
|
||||
if (initialize) setInitializing(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = { path: newPath, initialize };
|
||||
if (resetOnboarding) body.resetOnboarding = true;
|
||||
const res = await fetch("/api/settings/workspace", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: pathInput.trim(), initialize }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchWorkspace();
|
||||
|
||||
// If path actually changed, dispatch event
|
||||
if (oldPath && oldPath !== newPath) {
|
||||
window.dispatchEvent(new CustomEvent('assistant-workspace-switched', {
|
||||
detail: { oldPath, newPath },
|
||||
}));
|
||||
}
|
||||
|
||||
// Navigate to assistant session
|
||||
try {
|
||||
const model = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') || '' : '';
|
||||
const provider_id = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') || '' : '';
|
||||
const sessionMode = navigateMode === 'reuse' ? 'checkin' : 'onboarding';
|
||||
const sessionRes = await fetch("/api/workspace/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: sessionMode, model, provider_id }),
|
||||
});
|
||||
if (sessionRes.ok) {
|
||||
const sessionData = await sessionRes.json();
|
||||
window.dispatchEvent(new CustomEvent("session-created"));
|
||||
router.push(`/chat/${sessionData.session.id}`);
|
||||
}
|
||||
} catch (navErr) {
|
||||
console.error("Failed to auto-navigate to session:", navErr);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to save workspace path:", e);
|
||||
} finally {
|
||||
setInitializing(false);
|
||||
setConfirmDialog(null);
|
||||
}
|
||||
}, [pathInput, fetchWorkspace]);
|
||||
}, [pathInput, fetchWorkspace, workspace?.path, router]);
|
||||
|
||||
// Inspect path and show confirmation dialog (or save directly if path unchanged)
|
||||
const handleSaveClick = useCallback(async () => {
|
||||
if (!pathInput.trim()) return;
|
||||
|
||||
// If path hasn't changed, no-op
|
||||
if (pathInput.trim() === workspace?.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInspecting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/workspace/inspect?path=${encodeURIComponent(pathInput.trim())}`);
|
||||
if (!res.ok) {
|
||||
setPathValidation('invalid');
|
||||
setPathError(t('assistant.inspectFailed'));
|
||||
return;
|
||||
}
|
||||
const data: WorkspaceInspectResult = await res.json();
|
||||
|
||||
// Non-existent path — offer to create it
|
||||
if (!data.exists) {
|
||||
setConfirmDialog({ kind: 'not_found' });
|
||||
return;
|
||||
}
|
||||
// Check for invalid states — show error, block save
|
||||
if (!data.isDirectory) {
|
||||
setPathValidation('invalid');
|
||||
setPathError(t('assistant.pathNotDirectory'));
|
||||
return;
|
||||
}
|
||||
if (!data.readable) {
|
||||
setPathValidation('invalid');
|
||||
setPathError(t('assistant.pathNotReadable'));
|
||||
return;
|
||||
}
|
||||
if (!data.writable) {
|
||||
setPathValidation('invalid');
|
||||
setPathError(t('assistant.pathNotWritable'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation dialog based on workspace status
|
||||
switch (data.workspaceStatus) {
|
||||
case 'empty':
|
||||
setConfirmDialog({ kind: 'empty' });
|
||||
break;
|
||||
case 'normal_directory':
|
||||
setConfirmDialog({ kind: 'normal_directory' });
|
||||
break;
|
||||
case 'existing_workspace':
|
||||
setConfirmDialog({
|
||||
kind: 'existing_workspace',
|
||||
summary: data.summary || { onboardingComplete: false, lastCheckInDate: null, fileCount: 0 },
|
||||
});
|
||||
break;
|
||||
case 'partial_workspace':
|
||||
setConfirmDialog({ kind: 'partial_workspace' });
|
||||
break;
|
||||
default:
|
||||
setPathValidation('invalid');
|
||||
setPathError(t('assistant.pathInvalid'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to inspect workspace:", e);
|
||||
setPathValidation('invalid');
|
||||
setPathError(t('assistant.inspectFailed'));
|
||||
} finally {
|
||||
setInspecting(false);
|
||||
}
|
||||
}, [pathInput, workspace?.path, executeSave, t]);
|
||||
|
||||
const handleSelectFolder = useCallback(async () => {
|
||||
try {
|
||||
@@ -88,15 +325,19 @@ export function AssistantWorkspaceSection() {
|
||||
const selected = await w.electronAPI.selectFolder();
|
||||
if (selected) {
|
||||
setPathInput(selected);
|
||||
validatePath(selected);
|
||||
}
|
||||
} else {
|
||||
const input = prompt("Enter workspace directory path:");
|
||||
if (input) setPathInput(input);
|
||||
if (input) {
|
||||
setPathInput(input);
|
||||
validatePath(input);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to select folder:", e);
|
||||
}
|
||||
}, []);
|
||||
}, [validatePath]);
|
||||
|
||||
const handleRefreshDocs = useCallback(async () => {
|
||||
setRefreshingDocs(true);
|
||||
@@ -109,7 +350,6 @@ export function AssistantWorkspaceSection() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Create a new onboarding session in the assistant project folder and navigate to it */
|
||||
const handleStartOnboarding = useCallback(async () => {
|
||||
if (!workspace?.path) return;
|
||||
setCreatingSession(true);
|
||||
@@ -133,7 +373,6 @@ export function AssistantWorkspaceSection() {
|
||||
}
|
||||
}, [workspace?.path, router]);
|
||||
|
||||
/** Create or reuse a check-in session in the assistant project folder and navigate to it */
|
||||
const handleStartCheckIn = useCallback(async () => {
|
||||
if (!workspace?.path) return;
|
||||
setCreatingSession(true);
|
||||
@@ -157,6 +396,33 @@ export function AssistantWorkspaceSection() {
|
||||
}
|
||||
}, [workspace?.path, router]);
|
||||
|
||||
const handleReindex = useCallback(async () => {
|
||||
setReindexing(true);
|
||||
try {
|
||||
await fetch("/api/workspace/index", { method: "POST" });
|
||||
await fetchIndexStats();
|
||||
} catch (e) {
|
||||
console.error("Failed to reindex:", e);
|
||||
} finally {
|
||||
setReindexing(false);
|
||||
}
|
||||
}, [fetchIndexStats]);
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
setArchiving(true);
|
||||
try {
|
||||
await fetch("/api/workspace/organize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: 'archive' }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to archive:", e);
|
||||
} finally {
|
||||
setArchiving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -168,6 +434,27 @@ export function AssistantWorkspaceSection() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const checkInDoneToday = workspace?.state?.lastCheckInDate === today;
|
||||
|
||||
const tabs: Array<{ id: TabId; label: string }> = [
|
||||
{ id: 'files', label: t('assistant.fileStatus') },
|
||||
{ id: 'taxonomy', label: t('assistant.taxonomyTitle') },
|
||||
{ id: 'index', label: t('assistant.indexTitle') },
|
||||
{ id: 'organize', label: t('assistant.organizeTitle') },
|
||||
];
|
||||
|
||||
// Render path validation indicator
|
||||
const renderValidationIcon = () => {
|
||||
switch (pathValidation) {
|
||||
case 'checking':
|
||||
return <HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin text-muted-foreground" />;
|
||||
case 'valid':
|
||||
return <HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-4 w-4 text-green-500" />;
|
||||
case 'invalid':
|
||||
return <HugeiconsIcon icon={Cancel01Icon} className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Workspace Path Card */}
|
||||
@@ -175,74 +462,63 @@ export function AssistantWorkspaceSection() {
|
||||
<h2 className="text-sm font-medium">{t('assistant.workspacePath')}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('assistant.workspacePathHint')}</p>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
placeholder="/path/to/workspace"
|
||||
className="flex-1 rounded-md border border-border/50 bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput}
|
||||
onChange={(e) => handlePathInputChange(e.target.value)}
|
||||
placeholder="/path/to/workspace"
|
||||
className="w-full rounded-md border border-border/50 bg-background px-3 py-1.5 text-sm pr-8"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
{renderValidationIcon()}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleSelectFolder}>
|
||||
{t('assistant.selectFolder')}
|
||||
</Button>
|
||||
</div>
|
||||
{pathError && (
|
||||
<p className="text-xs text-red-500 mt-1">{pathError}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Button size="sm" onClick={() => handleSavePath(false)} disabled={!pathInput.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSavePath(true)}
|
||||
disabled={!pathInput.trim() || initializing}
|
||||
onClick={handleSaveClick}
|
||||
disabled={!pathInput.trim() || inspecting || pathValidation === 'invalid'}
|
||||
>
|
||||
{initializing ? (
|
||||
{inspecting ? (
|
||||
<>
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
{t('assistant.initializing')}
|
||||
{t('assistant.inspecting')}
|
||||
</>
|
||||
) : (
|
||||
t('assistant.initialize')
|
||||
t('common.save')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Status Card */}
|
||||
{workspace?.path && (
|
||||
<div className="rounded-lg border border-border/50 p-4">
|
||||
<h2 className="text-sm font-medium">{t('assistant.fileStatus')}</h2>
|
||||
<div className="mt-3 space-y-2">
|
||||
{Object.entries(FILE_LABELS).map(([key, label]) => {
|
||||
const file = workspace.files[key];
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between text-sm">
|
||||
<span className="font-mono text-xs">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{file?.exists ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('assistant.fileChars', { count: String(file.chars) })}
|
||||
</span>
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-xs text-green-600">{t('assistant.fileExists')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-2 w-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-xs text-yellow-600">{t('assistant.fileMissing')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Invalid workspace path warning */}
|
||||
{workspace?.path && workspace.valid === false && (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-4">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{t('assistant.workspaceInvalid')}: {workspace.reason === 'path_not_found'
|
||||
? t('assistant.pathNotExist')
|
||||
: workspace.reason === 'not_a_directory'
|
||||
? t('assistant.pathNotDirectory')
|
||||
: workspace.reason === 'not_readable'
|
||||
? t('assistant.pathNotReadable')
|
||||
: workspace.reason === 'not_writable'
|
||||
? t('assistant.pathNotWritable')
|
||||
: t('assistant.pathInvalid')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Onboarding Status Card */}
|
||||
{workspace?.path && (
|
||||
{workspace?.path && workspace.valid !== false && (
|
||||
<div className="rounded-lg border border-border/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -272,8 +548,8 @@ export function AssistantWorkspaceSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily Check-in Card */}
|
||||
{workspace?.path && (
|
||||
{/* Daily Check-in Card — only shown when onboarding is complete */}
|
||||
{workspace?.path && workspace.valid !== false && workspace.state?.onboardingComplete && (
|
||||
<div className="rounded-lg border border-border/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -308,31 +584,326 @@ export function AssistantWorkspaceSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Directory Docs Card */}
|
||||
{workspace?.path && (
|
||||
{/* Tabbed Section: Files / Taxonomy / Index / Organize */}
|
||||
{workspace?.path && workspace.valid !== false && (
|
||||
<div className="rounded-lg border border-border/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">{t('assistant.refreshDocs')}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefreshDocs}
|
||||
disabled={refreshingDocs}
|
||||
>
|
||||
{refreshingDocs ? (
|
||||
<>
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
{t('assistant.refreshingDocs')}
|
||||
</>
|
||||
) : (
|
||||
t('assistant.refreshDocs')
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex gap-1 border-b border-border/50 mb-3">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-t transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-background text-foreground border-b-2 border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Files Tab */}
|
||||
{activeTab === 'files' && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(FILE_LABELS).map(([key, label]) => {
|
||||
const file = workspace.files[key];
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between text-sm">
|
||||
<span className="font-mono text-xs">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{file?.exists ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('assistant.fileChars', { count: String(file.chars) })}
|
||||
</span>
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<span className="text-xs text-green-600">{t('assistant.fileExists')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-2 w-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-xs text-yellow-600">{t('assistant.fileMissing')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex items-center justify-end mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefreshDocs}
|
||||
disabled={refreshingDocs}
|
||||
>
|
||||
{refreshingDocs ? (
|
||||
<>
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
{t('assistant.refreshingDocs')}
|
||||
</>
|
||||
) : (
|
||||
t('assistant.refreshDocs')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Taxonomy Tab */}
|
||||
{activeTab === 'taxonomy' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('assistant.taxonomyDesc')}</p>
|
||||
{taxonomy.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">{t('assistant.taxonomyEmpty')}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{taxonomy.map(cat => (
|
||||
<div key={cat.id} className="flex items-center justify-between text-xs border border-border/30 rounded px-2 py-1.5">
|
||||
<div>
|
||||
<span className="font-medium">{cat.label}</span>
|
||||
<span className="text-muted-foreground ml-2">{t('assistant.taxonomyRole')}: {cat.role}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{t('assistant.taxonomySource')}: {cat.source}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] ${
|
||||
cat.confidence > 0.7 ? 'bg-green-500/10 text-green-600' :
|
||||
cat.confidence > 0.4 ? 'bg-yellow-500/10 text-yellow-600' :
|
||||
'bg-red-500/10 text-red-600'
|
||||
}`}>
|
||||
{Math.round(cat.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Index Tab */}
|
||||
{activeTab === 'index' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('assistant.indexDesc')}</p>
|
||||
{indexStats ? (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="border border-border/30 rounded px-2 py-1.5">
|
||||
<span className="text-muted-foreground">{t('assistant.indexFiles', { count: String(indexStats.fileCount) })}</span>
|
||||
</div>
|
||||
<div className="border border-border/30 rounded px-2 py-1.5">
|
||||
<span className="text-muted-foreground">{t('assistant.indexChunks', { count: String(indexStats.chunkCount) })}</span>
|
||||
</div>
|
||||
<div className="border border-border/30 rounded px-2 py-1.5">
|
||||
<span className="text-muted-foreground">{t('assistant.indexStale', { count: String(indexStats.staleCount) })}</span>
|
||||
</div>
|
||||
<div className="border border-border/30 rounded px-2 py-1.5">
|
||||
<span className="text-muted-foreground">
|
||||
{t('assistant.indexLastIndexed')}: {indexStats.lastIndexed ? new Date(indexStats.lastIndexed).toLocaleString() : 'never'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">{t('common.loading')}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReindex}
|
||||
disabled={reindexing}
|
||||
>
|
||||
{reindexing ? (
|
||||
<>
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
{t('assistant.indexReindexing')}
|
||||
</>
|
||||
) : (
|
||||
t('assistant.indexReindex')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organize Tab */}
|
||||
{activeTab === 'organize' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('assistant.organizeDesc')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleArchive}
|
||||
disabled={archiving}
|
||||
>
|
||||
{archiving ? (
|
||||
<>
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
{t('assistant.organizeArchiving')}
|
||||
</>
|
||||
) : (
|
||||
t('assistant.organizeArchive')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Confirmation Dialogs ── */}
|
||||
|
||||
{/* Non-existent path — offer to create */}
|
||||
<AlertDialog open={confirmDialog?.kind === 'not_found'} onOpenChange={(open) => { if (!open) setConfirmDialog(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('assistant.confirmNotFoundTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('assistant.confirmNotFoundDesc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => executeSave(true)} disabled={initializing}>
|
||||
{initializing ? (
|
||||
<>
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
{t('assistant.initializing')}
|
||||
</>
|
||||
) : (
|
||||
t('assistant.confirmCreate')
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Empty directory confirmation */}
|
||||
<AlertDialog open={confirmDialog?.kind === 'empty'} onOpenChange={(open) => { if (!open) setConfirmDialog(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('assistant.confirmEmptyTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('assistant.confirmEmptyDesc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => executeSave(true)} disabled={initializing}>
|
||||
{initializing ? (
|
||||
<>
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
{t('assistant.initializing')}
|
||||
</>
|
||||
) : (
|
||||
t('assistant.confirmInitialize')
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Normal directory confirmation */}
|
||||
<AlertDialog open={confirmDialog?.kind === 'normal_directory'} onOpenChange={(open) => { if (!open) setConfirmDialog(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('assistant.confirmNormalTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>{t('assistant.confirmNormalDesc')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('assistant.confirmNormalHint')}</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => executeSave(true)} disabled={initializing}>
|
||||
{initializing ? (
|
||||
<>
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
{t('assistant.initializing')}
|
||||
</>
|
||||
) : (
|
||||
t('assistant.confirmInitialize')
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Existing workspace confirmation */}
|
||||
<AlertDialog open={confirmDialog?.kind === 'existing_workspace'} onOpenChange={(open) => { if (!open) setConfirmDialog(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('assistant.confirmExistingTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<p>{t('assistant.confirmExistingDesc')}</p>
|
||||
{confirmDialog?.kind === 'existing_workspace' && (
|
||||
<div className="rounded border border-border/50 p-3 space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('assistant.summaryOnboarding')}:</span>
|
||||
<span>{confirmDialog.summary.onboardingComplete
|
||||
? t('assistant.onboardingComplete')
|
||||
: t('assistant.onboardingNotStarted')
|
||||
}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('assistant.summaryLastCheckIn')}:</span>
|
||||
<span>{confirmDialog.summary.lastCheckInDate || t('assistant.summaryNever')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('assistant.summaryFileCount')}:</span>
|
||||
<span>{confirmDialog.summary.fileCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-col sm:flex-col gap-2">
|
||||
<Button size="sm" onClick={() => executeSave(false, false, 'reuse')} disabled={initializing}>
|
||||
{t('assistant.takeoverContinue')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => executeSave(false, true, 'new')} disabled={initializing}>
|
||||
{initializing ? (
|
||||
<>
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
{t('assistant.initializing')}
|
||||
</>
|
||||
) : (
|
||||
t('assistant.takeoverReonboard')
|
||||
)}
|
||||
</Button>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Partial workspace confirmation */}
|
||||
<AlertDialog open={confirmDialog?.kind === 'partial_workspace'} onOpenChange={(open) => { if (!open) setConfirmDialog(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('assistant.confirmPartialTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('assistant.confirmPartialDesc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => executeSave(true)} disabled={initializing}>
|
||||
{initializing ? (
|
||||
<>
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
{t('assistant.initializing')}
|
||||
</>
|
||||
) : (
|
||||
t('assistant.confirmRepair')
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user