diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4b01574..14880af 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,5 +1,16 @@ -import { useEffect, useMemo, useState } from "react"; -import { AlertTriangle, FolderOpen, Play } from "lucide-react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + AlertTriangle, + FolderOpen, + LayoutGrid, + MoonStar, + Plus, + Send, + Settings2, + SunMedium, + Upload, + Workflow, +} from "lucide-react"; import { createTask, getTaskEvents, @@ -12,20 +23,27 @@ import { TaskThreadView } from "./components/TaskThreadView"; import { ModelPicker } from "./components/ModelPicker"; import { getModelLabel, + getProviderRuntimeLabel, loadModelConfig, + saveModelConfig, type ModelConfig, + type Provider, } from "./lib/modelConfig"; import { selectWorkspaceFolder } from "./lib/workspacePicker"; import { isTauriRuntime } from "./lib/tauriRuntime"; +import { applyTheme, loadTheme, saveTheme, type AppTheme } from "./lib/theme"; const LAST_WORKSPACE_KEY = "codra_last_workspace"; +const TASK_SELECTIONS_KEY = "codra_task_model_selections"; + +interface TaskSelectionMeta { + selectedProvider: Provider; + selectedModel: string; + providerRuntimeStatus: string; +} function sortTasks(tasks: Task[]) { - return [...tasks].sort((a, b) => { - const aTime = parseTaskTimestamp(a.updatedAt); - const bTime = parseTaskTimestamp(b.updatedAt); - return bTime - aTime; - }); + return [...tasks].sort((a, b) => parseTaskTimestamp(b.updatedAt) - parseTaskTimestamp(a.updatedAt)); } function parseTaskTimestamp(input: string) { @@ -50,23 +68,41 @@ function basename(path: string) { } function formatStatusLabel(status?: string | null) { - if (!status) return "Local-first mode"; + if (!status) return "Draft"; return status .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/_/g, " ") .replace(/^./, (char) => char.toUpperCase()); } +function loadTaskSelections(): Record { + try { + const raw = localStorage.getItem(TASK_SELECTIONS_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +function saveTaskSelections(next: Record) { + try { + localStorage.setItem(TASK_SELECTIONS_KEY, JSON.stringify(next)); + } catch { + // ignore persistence failures in preview contexts + } +} + export default function App() { const [tasks, setTasks] = useState([]); const [selectedTaskId, setSelectedTaskId] = useState(null); const [events, setEvents] = useState([]); const [workspacePath, setWorkspacePath] = useState(""); - const [workspaceContext, setWorkspaceContext] = - useState(null); - const [modelConfig, setModelConfig] = useState(() => - loadModelConfig(), - ); + const [workspaceContext, setWorkspaceContext] = useState(null); + const [modelConfig, setModelConfig] = useState(() => loadModelConfig()); + const [taskSelections, setTaskSelections] = useState>(() => loadTaskSelections()); + const [theme, setTheme] = useState(() => loadTheme()); const [prompt, setPrompt] = useState(""); const [error, setError] = useState(null); const [isCreatingTask, setIsCreatingTask] = useState(false); @@ -74,15 +110,32 @@ export default function App() { const isTauri = isTauriRuntime(); + useEffect(() => { + applyTheme(theme); + }, [theme]); + const selectedTask = useMemo(() => { if (!selectedTaskId) return null; return tasks.find((task) => task.id === selectedTaskId) || null; }, [tasks, selectedTaskId]); - const workspaceLabel = basename(workspacePath) || "Select workspace"; - const modelLabel = getModelLabel( - modelConfig.selectedProvider, - modelConfig.selectedModel, + const selectedTaskSelection = useMemo(() => { + if (!selectedTaskId) return null; + return taskSelections[selectedTaskId] ?? null; + }, [selectedTaskId, taskSelections]); + + const workspaceLabel = basename(workspacePath) || "Open project"; + const selectedModelLabel = getModelLabel(modelConfig.selectedProvider, modelConfig.selectedModel); + const selectedProviderRuntime = getProviderRuntimeLabel(modelConfig.selectedProvider); + const taskModelLabels = useMemo( + () => + Object.fromEntries( + Object.entries(taskSelections).map(([taskId, selection]) => [ + taskId, + getModelLabel(selection.selectedProvider, selection.selectedModel), + ]), + ), + [taskSelections], ); async function loadTasksForWorkspace(path: string) { @@ -97,9 +150,7 @@ export default function App() { const nextTasks = sortTasks(await listTasks(nextPath)); setTasks(nextTasks); setSelectedTaskId((current) => - current && nextTasks.some((task) => task.id === current) - ? current - : (nextTasks[0]?.id ?? null), + current && nextTasks.some((task) => task.id === current) ? current : (nextTasks[0]?.id ?? null), ); } @@ -122,10 +173,8 @@ export default function App() { console.error("[Codra] Failed to load tasks:", cause); } - if (lastWorkspace && !cancelled) { - if (isTauri) { - void scanWorkspaceAtPath(lastWorkspace); - } + if (lastWorkspace && !cancelled && isTauri) { + void scanWorkspaceAtPath(lastWorkspace); } } @@ -196,6 +245,14 @@ export default function App() { } } + function persistTaskSelection(taskId: string, selection: TaskSelectionMeta) { + setTaskSelections((current) => { + const next = { ...current, [taskId]: selection }; + saveTaskSelections(next); + return next; + }); + } + function handleNewThread() { setSelectedTaskId(null); setEvents([]); @@ -218,9 +275,7 @@ export default function App() { async function handleSelectWorkspace() { if (!isTauri) { - setError( - "Open Codra in the desktop app window to use the native folder picker.", - ); + setError("Open Codra in the desktop app window to use the native folder picker."); return; } @@ -251,9 +306,7 @@ export default function App() { } if (!isTauri) { - setError( - "Tauri runtime unavailable. Open Codra in the desktop app window.", - ); + setError("Tauri runtime unavailable. Open Codra in the desktop app window."); return; } @@ -264,18 +317,20 @@ export default function App() { const created = await createTask({ workspace_path: trimmedWorkspace, user_prompt: trimmedPrompt, - title: trimmedPrompt.slice(0, 60), + title: trimmedPrompt.slice(0, 72), }); setTasks((current) => upsertTask(current, created)); setSelectedTaskId(created.id); + persistTaskSelection(created.id, { + selectedProvider: modelConfig.selectedProvider, + selectedModel: modelConfig.selectedModel, + providerRuntimeStatus: getProviderRuntimeLabel(modelConfig.selectedProvider), + }); setPrompt(""); try { - const nextEvents = await getTaskEvents( - created.id, - created.workspacePath, - ); + const nextEvents = await getTaskEvents(created.id, created.workspacePath); setEvents(nextEvents); } catch { setEvents([]); @@ -293,239 +348,251 @@ export default function App() { } async function refreshEvents() { - if (!selectedTaskId) { + if (!selectedTaskId || !selectedTask) { setEvents([]); return; } try { - if (!selectedTask) { - setEvents([]); - return; - } - - const next = await getTaskEvents( - selectedTaskId, - selectedTask.workspacePath, - ); + const next = await getTaskEvents(selectedTaskId, selectedTask.workspacePath); setEvents(next); } catch { setEvents([]); } } - const canCreateTask = Boolean( - isTauri && workspacePath.trim() && prompt.trim() && !isCreatingTask, - ); + const canCreateTask = Boolean(isTauri && workspacePath.trim() && prompt.trim() && !isCreatingTask); + + function handleThemeToggle() { + setTheme((current) => { + const next = current === "dark" ? "light" : "dark"; + saveTheme(next); + return next; + }); + } + + function updateModelConfig(next: ModelConfig) { + const persisted = saveModelConfig(next); + setModelConfig(persisted); + } return ( -
-
-
-
- - -
-
-
-
- Codra interface artifact -
-
-

- {selectedTask - ? selectedTask.title || "Task thread" - : "What should Codra do in this workspace?"} -

- {selectedTask ? ( - - {formatStatusLabel(selectedTask.status)} - - ) : ( - - Local-first mode +
+
+
+ + +
+
+
+
+ Pick a model. Open a project. Prompt. Review. Ship. +
+
+

+ {selectedTask ? selectedTask.title || "Task thread" : "What should Codra build?"} +

+ + {selectedTask ? formatStatusLabel(selectedTask.status) : "Draft thread"} - )} +
-
-
- +
+ + } label="Handoff" /> + } label="Push" /> + + + +
+
-
-
- Daemon connected + {!isTauri && ( +
+ + + Tauri runtime unavailable. Native folder selection and task execution only work inside the desktop app window. +
-
- - - {!isTauri && ( -
- - - Tauri runtime unavailable — native folder selection and task - execution are only available in the desktop app window. - -
- )} - -
- {!selectedTask ? ( -
-
-
-
- Codra interface artifact -
-

- What should Codra do in this workspace? -

-

- Codra scans the workspace, drafts a plan, and waits for - your approval before it touches files. -

-
+ )} -
-
- - - +
+ {!selectedTask ? ( +
+
+
+
+ Open-source agentic coding app for TeraAI +
+

+ What should Codra build? +

+

+ Codra will show a plan before changing files. +

- {workspaceContext ? ( -
- - Stack:{" "} - {workspaceContext.detectedStack - .slice(0, 2) - .join(" · ") || "Unknown"} - - {workspaceContext.gitBranch && ( - - Branch: {workspaceContext.gitBranch} - - )} - - {workspaceContext.isGitRepo - ? "Git repo" - : "Not a git repo"} - - {workspaceContext.detectedConfigFiles - .slice(0, 2) - .map((file) => ( - - {file} - - ))} +
+
+
+ }> + {workspaceLabel} + + {workspaceContext?.gitBranch ? `local • ${workspaceContext.gitBranch}` : "local workspace"} + {selectedModelLabel} + {selectedProviderRuntime} + {isScanningWorkspace ? Scanning workspace… : null} +
+
- ) : null} -
- - {isScanningWorkspace && ( - - - Scanning workspace… - - )} +