feat: assistant workspace — persistent AI personality & memory via Markdown files (#165)
Add "Assistant Workspace" feature that lets users designate a directory (e.g. Obsidian vault) containing 4 Markdown files (soul.md, user.md, memory.md, claude.md) to define AI personality, user profile, long-term memory, and execution rules. These files are automatically injected into the system prompt for assistant project sessions. Core library (src/lib/assistant-workspace.ts): - Workspace validation, initialization with template files - File loading with 8000-char truncation, XML prompt assembly (40k limit) - State management via .assistant/state.json - Directory docs generation (README.ai.md + PATH.ai.md) - Case-insensitive file name fallback (soul.md → SOUL.md) System prompt injection (src/app/api/chat/route.ts): - Scoped to sessions where working_directory === workspace path - Workspace prompt prepended before session-level system prompt - Onboarding/check-in instructions injected as <assistant-project-task> - autoTrigger flag skips user message save and title update Chat-based onboarding (10 questions) & daily check-in (3 questions): - AI conducts conversation, outputs structured completion code blocks - Frontend detects onboarding-complete/checkin-complete blocks - Backend calls Claude API to generate file content from answers - Fallback to raw answers if API call fails Auto-trigger pipeline: - Mount trigger (500ms delay) + focus fallback in MessageInput - assistantTriggerFiredRef prevents duplicate triggers per mount - hookTriggeredSessionId in state.json prevents cross-reload duplicates - Daily check-in only triggers in the most recent workspace session - hookTriggeredSessionId cleared via __clear__ sentinel after completion API endpoints: - GET/PUT /api/settings/workspace — path CRUD + file status - POST /api/workspace/session — create (onboarding) or reuse (check-in) - POST /api/workspace/onboarding — AI-generate soul.md + user.md - POST /api/workspace/checkin — AI-update memory.md + user.md - POST /api/workspace/hook-triggered — set/clear trigger guard - GET /api/workspace/latest-session — latest session lookup - POST /api/workspace/docs — refresh directory documentation UI: - Settings "Assistant" tab with workspace path, file status, controls - Violet badge on workspace project group in ChatListPanel - useAssistantWorkspace hook for state queries Also includes: - 102 passing tests (16 new regression tests for workspace logic) - i18n keys for en.ts and zh.ts - Handover documentation and execution plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
3f19290c8d
commit
19aba25c8f
24 files changed
+1812
-24
No files matched your search
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot } from '@/types';
|
||||
import { MessageList } from './MessageList';
|
||||
import { MessageInput } from './MessageInput';
|
||||
@@ -136,6 +136,9 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
// Append the final assistant message to the messages list
|
||||
const finalContent = event.snapshot.finalMessageContent;
|
||||
if (finalContent) {
|
||||
// Check for assistant project completion signals
|
||||
detectAssistantCompletion(finalContent);
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: 'temp-assistant-' + Date.now(),
|
||||
session_id: sessionId,
|
||||
@@ -180,6 +183,82 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
setHasMore(initialHasMore);
|
||||
}, [initialHasMore]);
|
||||
|
||||
// Auto-trigger assistant project hooks (onboarding/check-in)
|
||||
// Uses autoTrigger flag so the backend skips saving user message and title update.
|
||||
// Works for both fresh sessions (onboarding) and reused sessions (check-in).
|
||||
const assistantTriggerFiredRef = useRef(false);
|
||||
const checkAssistantTrigger = useCallback(async () => {
|
||||
// Don't trigger if already streaming or already triggered in this mount
|
||||
if (isStreaming || assistantTriggerFiredRef.current) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings/workspace');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!data.path) return;
|
||||
|
||||
// Check if this session's working directory matches workspace path
|
||||
if (workingDirectory !== data.path) return;
|
||||
|
||||
const state = data.state;
|
||||
if (!state) return;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const needsOnboarding = !state.onboardingComplete;
|
||||
const needsCheckIn = state.onboardingComplete && state.lastCheckInDate !== today;
|
||||
|
||||
if (!needsOnboarding && !needsCheckIn) return;
|
||||
|
||||
// For daily check-in, only trigger in the most recent session for this workspace.
|
||||
// This prevents older sessions from hijacking the check-in when reopened.
|
||||
if (needsCheckIn) {
|
||||
const latestRes = await fetch(`/api/workspace/latest-session?workingDirectory=${encodeURIComponent(data.path)}`);
|
||||
if (latestRes.ok) {
|
||||
const { sessionId: latestSessionId } = await latestRes.json();
|
||||
if (latestSessionId && latestSessionId !== sessionId) return;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark fired so we don't re-trigger on focus/re-render
|
||||
assistantTriggerFiredRef.current = true;
|
||||
|
||||
// Mark in persistent state to prevent duplicate triggers across page reloads
|
||||
await fetch('/api/workspace/hook-triggered', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
|
||||
// Use autoTrigger: the message is invisible (no user bubble, no title update)
|
||||
const triggerMsg = needsOnboarding
|
||||
? '请开始助理引导设置。'
|
||||
: '请开始每日问询。';
|
||||
startStream({
|
||||
sessionId,
|
||||
content: triggerMsg,
|
||||
mode,
|
||||
model: currentModel,
|
||||
providerId: currentProviderId,
|
||||
autoTrigger: true,
|
||||
onModeChanged: (sdkMode) => {
|
||||
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
|
||||
handleModeChange(uiMode);
|
||||
},
|
||||
sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => {
|
||||
sendMessageRef.current?.(retryContent, retryFiles);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Assistant auto-trigger failed:', e);
|
||||
}
|
||||
}, [sessionId, workingDirectory, isStreaming, mode, currentModel, currentProviderId, handleModeChange]);
|
||||
|
||||
useEffect(() => {
|
||||
// Small delay to let the session fully initialize
|
||||
const timer = setTimeout(checkAssistantTrigger, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkAssistantTrigger]);
|
||||
|
||||
const loadEarlierMessages = useCallback(async () => {
|
||||
// Use ref as atomic lock to prevent double-fetch from rapid clicks
|
||||
if (loadingMoreRef.current || !hasMore || messages.length === 0) return;
|
||||
@@ -217,6 +296,49 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
[sessionId, setPendingApprovalSessionId]
|
||||
);
|
||||
|
||||
// Detect assistant project completion signals in final message content
|
||||
const detectAssistantCompletion = useCallback(async (content: string) => {
|
||||
const clearHookTriggered = () =>
|
||||
fetch('/api/workspace/hook-triggered', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: '__clear__' }),
|
||||
}).catch(() => {});
|
||||
|
||||
// Check for onboarding completion
|
||||
const onboardingMatch = content.match(/```onboarding-complete\n([\s\S]*?)\n```/);
|
||||
if (onboardingMatch) {
|
||||
try {
|
||||
const answers = JSON.parse(onboardingMatch[1]);
|
||||
await fetch('/api/workspace/onboarding', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers }),
|
||||
});
|
||||
await clearHookTriggered();
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Onboarding completion failed:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for check-in completion
|
||||
const checkinMatch = content.match(/```checkin-complete\n([\s\S]*?)\n```/);
|
||||
if (checkinMatch) {
|
||||
try {
|
||||
const answers = JSON.parse(checkinMatch[1]);
|
||||
await fetch('/api/workspace/checkin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers }),
|
||||
});
|
||||
await clearHookTriggered();
|
||||
} catch (e) {
|
||||
console.error('[ChatView] Check-in completion failed:', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Send message — delegates stream management to the manager
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => {
|
||||
@@ -420,6 +542,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
workingDirectory={workingDirectory}
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
onAssistantTrigger={checkAssistantTrigger}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -97,6 +97,7 @@ interface MessageInputProps {
|
||||
workingDirectory?: string;
|
||||
mode?: string;
|
||||
onModeChange?: (mode: string) => void;
|
||||
onAssistantTrigger?: () => void;
|
||||
}
|
||||
|
||||
interface PopoverItem {
|
||||
@@ -373,6 +374,7 @@ export function MessageInput({
|
||||
workingDirectory,
|
||||
mode = 'code',
|
||||
onModeChange,
|
||||
onAssistantTrigger,
|
||||
}: MessageInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const imageGen = useImageGen();
|
||||
@@ -396,6 +398,16 @@ export function MessageInput({
|
||||
const aiSearchAbortRef = useRef<AbortController | null>(null);
|
||||
const aiSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Assistant trigger on first focus
|
||||
const assistantTriggerFired = useRef(false);
|
||||
|
||||
const handleAssistantFocus = useCallback(() => {
|
||||
if (!assistantTriggerFired.current && onAssistantTrigger) {
|
||||
assistantTriggerFired.current = true;
|
||||
onAssistantTrigger();
|
||||
}
|
||||
}, [onAssistantTrigger]);
|
||||
|
||||
// Fetch provider groups from API
|
||||
const fetchProviderModels = useCallback(() => {
|
||||
fetch('/api/providers/models')
|
||||
@@ -1098,6 +1110,7 @@ export function MessageInput({
|
||||
value={inputValue}
|
||||
onChange={(e) => handleInputChange(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleAssistantFocus}
|
||||
disabled={disabled}
|
||||
className="min-h-10"
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ArrowRight01Icon,
|
||||
PlusSignIcon,
|
||||
FolderOpenIcon,
|
||||
AiUserIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { Columns2, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -32,6 +33,7 @@ import { useNativeFolderPicker } from "@/hooks/useNativeFolderPicker";
|
||||
import { ConnectionStatus } from "./ConnectionStatus";
|
||||
import { ImportSessionDialog } from "./ImportSessionDialog";
|
||||
import { FolderPicker } from "@/components/chat/FolderPicker";
|
||||
import { useAssistantWorkspace } from "@/hooks/useAssistantWorkspace";
|
||||
import type { ChatSession } from "@/types";
|
||||
|
||||
interface ChatListPanelProps {
|
||||
@@ -130,6 +132,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
|
||||
);
|
||||
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
|
||||
const [creatingChat, setCreatingChat] = useState(false);
|
||||
const { workspacePath } = useAssistantWorkspace();
|
||||
|
||||
/** Read current model + provider_id from localStorage for new session creation */
|
||||
const getCurrentModelAndProvider = useCallback(() => {
|
||||
@@ -376,7 +379,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
|
||||
style={{ width: width ?? 240 }}
|
||||
>
|
||||
{/* Header - extra top padding for macOS traffic lights */}
|
||||
<div className="flex h-12 shrink-0 items-center px-3 mt-5">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between px-3 mt-5">
|
||||
<ConnectionStatus />
|
||||
</div>
|
||||
|
||||
@@ -563,6 +566,9 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
|
||||
<span className="flex-1 truncate text-[13px] font-medium text-sidebar-foreground">
|
||||
{group.displayName}
|
||||
</span>
|
||||
{workspacePath && group.workingDirectory === workspacePath && (
|
||||
<HugeiconsIcon icon={AiUserIcon} className="h-3.5 w-3.5 shrink-0 text-violet-500" />
|
||||
)}
|
||||
{/* New chat in project button (on hover) */}
|
||||
{group.workingDirectory !== "" && (
|
||||
<Tooltip>
|
||||
@@ -735,6 +741,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) {
|
||||
onOpenChange={setFolderPickerOpen}
|
||||
onSelect={handleFolderSelect}
|
||||
/>
|
||||
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Loading02Icon } from "@hugeicons/core-free-icons";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
|
||||
interface FileStatus {
|
||||
exists: boolean;
|
||||
chars: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
interface WorkspaceState {
|
||||
onboardingComplete: boolean;
|
||||
lastCheckInDate: string | null;
|
||||
schemaVersion: number;
|
||||
}
|
||||
|
||||
interface WorkspaceInfo {
|
||||
path: string | null;
|
||||
exists?: boolean;
|
||||
files: Record<string, FileStatus>;
|
||||
state: WorkspaceState | null;
|
||||
}
|
||||
|
||||
const FILE_LABELS: Record<string, string> = {
|
||||
claude: "claude.md",
|
||||
soul: "soul.md",
|
||||
user: "user.md",
|
||||
memory: "memory.md",
|
||||
};
|
||||
|
||||
export function AssistantWorkspaceSection() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [workspace, setWorkspace] = useState<WorkspaceInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [initializing, setInitializing] = useState(false);
|
||||
const [refreshingDocs, setRefreshingDocs] = useState(false);
|
||||
const [pathInput, setPathInput] = useState("");
|
||||
const [creatingSession, setCreatingSession] = useState(false);
|
||||
|
||||
const fetchWorkspace = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings/workspace");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setWorkspace(data);
|
||||
if (data.path) setPathInput(data.path);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch workspace:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkspace();
|
||||
}, [fetchWorkspace]);
|
||||
|
||||
const handleSavePath = useCallback(async (initialize: boolean) => {
|
||||
if (!pathInput.trim()) return;
|
||||
if (initialize) setInitializing(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/workspace", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: pathInput.trim(), initialize }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchWorkspace();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to save workspace path:", e);
|
||||
} finally {
|
||||
setInitializing(false);
|
||||
}
|
||||
}, [pathInput, fetchWorkspace]);
|
||||
|
||||
const handleSelectFolder = useCallback(async () => {
|
||||
try {
|
||||
const w = window as unknown as { electronAPI?: { selectFolder?: () => Promise<string | null> } };
|
||||
if (w.electronAPI?.selectFolder) {
|
||||
const selected = await w.electronAPI.selectFolder();
|
||||
if (selected) {
|
||||
setPathInput(selected);
|
||||
}
|
||||
} else {
|
||||
const input = prompt("Enter workspace directory path:");
|
||||
if (input) setPathInput(input);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to select folder:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefreshDocs = useCallback(async () => {
|
||||
setRefreshingDocs(true);
|
||||
try {
|
||||
await fetch("/api/workspace/docs", { method: "POST" });
|
||||
} catch (e) {
|
||||
console.error("Failed to refresh docs:", e);
|
||||
} finally {
|
||||
setRefreshingDocs(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Create a new onboarding session in the assistant project folder and navigate to it */
|
||||
const handleStartOnboarding = useCallback(async () => {
|
||||
if (!workspace?.path) return;
|
||||
setCreatingSession(true);
|
||||
try {
|
||||
const model = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') || '' : '';
|
||||
const provider_id = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') || '' : '';
|
||||
const res = await fetch("/api/workspace/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: 'onboarding', 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("Failed to create onboarding session:", e);
|
||||
} finally {
|
||||
setCreatingSession(false);
|
||||
}
|
||||
}, [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);
|
||||
try {
|
||||
const model = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') || '' : '';
|
||||
const provider_id = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') || '' : '';
|
||||
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("Failed to create check-in session:", e);
|
||||
} finally {
|
||||
setCreatingSession(false);
|
||||
}
|
||||
}, [workspace?.path, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const checkInDoneToday = workspace?.state?.lastCheckInDate === today;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Workspace Path Card */}
|
||||
<div className="rounded-lg border border-border/50 p-4">
|
||||
<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"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleSelectFolder}>
|
||||
{t('assistant.selectFolder')}
|
||||
</Button>
|
||||
</div>
|
||||
<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}
|
||||
>
|
||||
{initializing ? (
|
||||
<>
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
{t('assistant.initializing')}
|
||||
</>
|
||||
) : (
|
||||
t('assistant.initialize')
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Onboarding Status Card */}
|
||||
{workspace?.path && (
|
||||
<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.onboardingTitle')}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('assistant.onboardingDesc')}</p>
|
||||
<p className="text-xs mt-1">
|
||||
{workspace.state?.onboardingComplete
|
||||
? <span className="text-green-600">{t('assistant.onboardingComplete')}</span>
|
||||
: <span className="text-yellow-600">{t('assistant.onboardingNotStarted')}</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStartOnboarding}
|
||||
disabled={creatingSession}
|
||||
>
|
||||
{creatingSession ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin" />
|
||||
) : workspace.state?.onboardingComplete
|
||||
? t('assistant.redoOnboarding')
|
||||
: t('assistant.startOnboarding')
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily Check-in Card */}
|
||||
{workspace?.path && (
|
||||
<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.checkInTitle')}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('assistant.checkInDesc')}</p>
|
||||
<p className="text-xs mt-1">
|
||||
{workspace.state?.lastCheckInDate && (
|
||||
<span className="text-muted-foreground">
|
||||
{t('assistant.lastCheckIn')}: {workspace.state.lastCheckInDate}
|
||||
</span>
|
||||
)}
|
||||
{" "}
|
||||
{checkInDoneToday
|
||||
? <span className="text-green-600">{t('assistant.checkInToday')}</span>
|
||||
: <span className="text-yellow-600">{t('assistant.checkInNeeded')}</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStartCheckIn}
|
||||
disabled={creatingSession}
|
||||
>
|
||||
{creatingSession ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
t('assistant.startCheckIn')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Directory Docs Card */}
|
||||
{workspace?.path && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { IconSvgElement } from "@hugeicons/react";
|
||||
import {
|
||||
Settings02Icon,
|
||||
CodeIcon,
|
||||
AiUserIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { Plug01Icon, Analytics02Icon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -13,10 +14,11 @@ import { GeneralSection } from "./GeneralSection";
|
||||
import { ProviderManager } from "./ProviderManager";
|
||||
import { CliSettingsSection } from "./CliSettingsSection";
|
||||
import { UsageStatsSection } from "./UsageStatsSection";
|
||||
import { AssistantWorkspaceSection } from "./AssistantWorkspaceSection";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import type { TranslationKey } from "@/i18n";
|
||||
|
||||
type Section = "general" | "providers" | "cli" | "usage";
|
||||
type Section = "general" | "providers" | "cli" | "usage" | "assistant";
|
||||
|
||||
interface SidebarItem {
|
||||
id: Section;
|
||||
@@ -29,6 +31,7 @@ const sidebarItems: SidebarItem[] = [
|
||||
{ id: "providers", label: "Providers", icon: Plug01Icon },
|
||||
{ id: "cli", label: "Claude CLI", icon: CodeIcon },
|
||||
{ id: "usage", label: "Usage", icon: Analytics02Icon },
|
||||
{ id: "assistant", label: "Assistant", icon: AiUserIcon },
|
||||
];
|
||||
|
||||
function getSectionFromHash(): Section {
|
||||
@@ -61,6 +64,7 @@ export function SettingsLayout() {
|
||||
'Providers': 'settings.providers',
|
||||
'Claude CLI': 'settings.claudeCli',
|
||||
'Usage': 'settings.usage',
|
||||
'Assistant': 'settings.assistant',
|
||||
};
|
||||
|
||||
const handleSectionChange = useCallback((section: Section) => {
|
||||
@@ -105,6 +109,7 @@ export function SettingsLayout() {
|
||||
{activeSection === "providers" && <ProviderManager />}
|
||||
{activeSection === "cli" && <CliSettingsSection />}
|
||||
{activeSection === "usage" && <UsageStatsSection />}
|
||||
{activeSection === "assistant" && <AssistantWorkspaceSection />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user