diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index 347bd6063..e2a3b38ec 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -760,3 +760,238 @@ Changes:\n{diff}" Ok(trimmed) } + +#[tauri::command] +pub(crate) async fn generate_run_metadata( + workspace_id: String, + prompt: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + return remote_backend::call_remote( + &*state, + app, + "generate_run_metadata", + json!({ "workspaceId": workspace_id, "prompt": prompt }), + ) + .await; + } + + let cleaned_prompt = prompt.trim(); + if cleaned_prompt.is_empty() { + return Err("Prompt is required.".to_string()); + } + + let session = { + let sessions = state.sessions.lock().await; + sessions + .get(&workspace_id) + .ok_or("workspace not connected")? + .clone() + }; + + let title_prompt = format!( + "You create concise run metadata for a coding task.\n\ +Return ONLY a JSON object with keys:\n\ +- title: short, clear, 3-7 words, Title Case\n\ +- worktreeName: lower-case, kebab-case slug prefixed with one of: \ +feat/, fix/, chore/, test/, docs/, refactor/, perf/, build/, ci/, style/.\n\ +\n\ +Choose fix/ when the task is a bug fix, error, regression, crash, or cleanup. \ +Use the closest match for chores/tests/docs/refactors/perf/build/ci/style. \ +Otherwise use feat/.\n\ +\n\ +Examples:\n\ +{{\"title\":\"Fix Login Redirect Loop\",\"worktreeName\":\"fix/login-redirect-loop\"}}\n\ +{{\"title\":\"Add Workspace Home View\",\"worktreeName\":\"feat/workspace-home\"}}\n\ +{{\"title\":\"Update Lint Config\",\"worktreeName\":\"chore/update-lint-config\"}}\n\ +{{\"title\":\"Add Coverage Tests\",\"worktreeName\":\"test/add-coverage-tests\"}}\n\ +\n\ +Task:\n{cleaned_prompt}" + ); + + let thread_params = json!({ + "cwd": session.entry.path, + "approvalPolicy": "never" + }); + let thread_result = session.send_request("thread/start", thread_params).await?; + + if let Some(error) = thread_result.get("error") { + let error_msg = error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error starting thread"); + return Err(error_msg.to_string()); + } + + let thread_id = thread_result + .get("result") + .and_then(|r| r.get("threadId")) + .or_else(|| thread_result.get("result").and_then(|r| r.get("thread")).and_then(|t| t.get("id"))) + .or_else(|| thread_result.get("threadId")) + .or_else(|| thread_result.get("thread").and_then(|t| t.get("id"))) + .and_then(|t| t.as_str()) + .ok_or_else(|| format!("Failed to get threadId from thread/start response: {:?}", thread_result))? + .to_string(); + + let (tx, mut rx) = mpsc::unbounded_channel::(); + { + let mut callbacks = session.background_thread_callbacks.lock().await; + callbacks.insert(thread_id.clone(), tx); + } + + let turn_params = json!({ + "threadId": thread_id, + "input": [{ "type": "text", "text": title_prompt }], + "cwd": session.entry.path, + "approvalPolicy": "never", + "sandboxPolicy": { "type": "readOnly" }, + }); + let turn_result = session.send_request("turn/start", turn_params).await; + let turn_result = match turn_result { + Ok(result) => result, + Err(error) => { + { + let mut callbacks = session.background_thread_callbacks.lock().await; + callbacks.remove(&thread_id); + } + let archive_params = json!({ "threadId": thread_id.as_str() }); + let _ = session.send_request("thread/archive", archive_params).await; + return Err(error); + } + }; + + if let Some(error) = turn_result.get("error") { + let error_msg = error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error starting turn"); + { + let mut callbacks = session.background_thread_callbacks.lock().await; + callbacks.remove(&thread_id); + } + let archive_params = json!({ "threadId": thread_id.as_str() }); + let _ = session.send_request("thread/archive", archive_params).await; + return Err(error_msg.to_string()); + } + + let mut response_text = String::new(); + let timeout_duration = Duration::from_secs(60); + let collect_result = timeout(timeout_duration, async { + while let Some(event) = rx.recv().await { + let method = event.get("method").and_then(|m| m.as_str()).unwrap_or(""); + match method { + "item/agentMessage/delta" => { + if let Some(params) = event.get("params") { + if let Some(delta) = params.get("delta").and_then(|d| d.as_str()) { + response_text.push_str(delta); + } + } + } + "turn/completed" => break, + "turn/error" => { + let error_msg = event + .get("params") + .and_then(|p| p.get("error")) + .and_then(|e| e.as_str()) + .unwrap_or("Unknown error during metadata generation"); + return Err(error_msg.to_string()); + } + _ => {} + } + } + Ok(()) + }) + .await; + + { + let mut callbacks = session.background_thread_callbacks.lock().await; + callbacks.remove(&thread_id); + } + + let archive_params = json!({ "threadId": thread_id }); + let _ = session.send_request("thread/archive", archive_params).await; + + match collect_result { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(e), + Err(_) => return Err("Timeout waiting for metadata generation".to_string()), + } + + let trimmed = response_text.trim(); + if trimmed.is_empty() { + return Err("No metadata was generated".to_string()); + } + + let json_value = extract_json_value(trimmed) + .ok_or_else(|| "Failed to parse metadata JSON".to_string())?; + let title = json_value + .get("title") + .and_then(|v| v.as_str()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .ok_or_else(|| "Missing title in metadata".to_string())?; + let worktree_name = json_value + .get("worktreeName") + .or_else(|| json_value.get("worktree_name")) + .and_then(|v| v.as_str()) + .map(|v| sanitize_run_worktree_name(v)) + .filter(|v| !v.is_empty()) + .ok_or_else(|| "Missing worktree name in metadata".to_string())?; + + Ok(json!({ + "title": title, + "worktreeName": worktree_name + })) +} + +fn extract_json_value(raw: &str) -> Option { + let start = raw.find('{')?; + let end = raw.rfind('}')?; + if end <= start { + return None; + } + serde_json::from_str::(&raw[start..=end]).ok() +} + +fn sanitize_run_worktree_name(value: &str) -> String { + let trimmed = value.trim().to_lowercase(); + let mut cleaned = String::new(); + let mut last_dash = false; + for ch in trimmed.chars() { + let next = if ch.is_ascii_alphanumeric() || ch == '/' { + last_dash = false; + Some(ch) + } else if ch == '-' || ch.is_whitespace() || ch == '_' { + if last_dash { + None + } else { + last_dash = true; + Some('-') + } + } else { + None + }; + if let Some(ch) = next { + cleaned.push(ch); + } + } + while cleaned.ends_with('-') || cleaned.ends_with('/') { + cleaned.pop(); + } + let allowed_prefixes = [ + "feat/", "fix/", "chore/", "test/", "docs/", "refactor/", "perf/", + "build/", "ci/", "style/", + ]; + if allowed_prefixes.iter().any(|prefix| cleaned.starts_with(prefix)) { + return cleaned; + } + for prefix in allowed_prefixes.iter() { + let dash_prefix = prefix.replace('/', "-"); + if cleaned.starts_with(&dash_prefix) { + return cleaned.replacen(&dash_prefix, prefix, 1); + } + } + format!("feat/{}", cleaned.trim_start_matches('/')) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 554b150bf..a6c8af757 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -87,6 +87,7 @@ pub fn run() { codex::remember_approval_rule, codex::get_commit_message_prompt, codex::generate_commit_message, + codex::generate_run_metadata, codex::resume_thread, codex::list_threads, codex::archive_thread, diff --git a/src/App.tsx b/src/App.tsx index a67c85ef0..c21e8b0eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import "./styles/base.css"; import "./styles/buttons.css"; import "./styles/sidebar.css"; import "./styles/home.css"; +import "./styles/workspace-home.css"; import "./styles/main.css"; import "./styles/messages.css"; import "./styles/approval-toasts.css"; @@ -83,6 +84,8 @@ import { useLiquidGlassEffect } from "./features/app/hooks/useLiquidGlassEffect" import { useCopyThread } from "./features/threads/hooks/useCopyThread"; import { useTerminalController } from "./features/terminal/hooks/useTerminalController"; import { useGitCommitController } from "./features/app/hooks/useGitCommitController"; +import { WorkspaceHome } from "./features/workspaces/components/WorkspaceHome"; +import { useWorkspaceHome } from "./features/workspaces/hooks/useWorkspaceHome"; import { pickWorkspacePath } from "./services/tauri"; import type { AccessMode, @@ -802,6 +805,7 @@ function MainApp() { activePlan && (activePlan.steps.length > 0 || activePlan.explanation) ); const showHome = !activeWorkspace; + const showWorkspaceHome = Boolean(activeWorkspace && !activeThreadId); const [usageMetric, setUsageMetric] = useState<"tokens" | "time">("tokens"); const [usageWorkspaceId, setUsageWorkspaceId] = useState(null); const usageWorkspaceOptions = useMemo( @@ -884,6 +888,28 @@ function MainApp() { textareaRef: composerInputRef, }); + const { + runs: workspaceRuns, + draft: workspacePrompt, + runMode: workspaceRunMode, + modelSelections: workspaceModelSelections, + error: workspaceRunError, + isSubmitting: workspaceRunSubmitting, + setDraft: setWorkspacePrompt, + setRunMode: setWorkspaceRunMode, + toggleModelSelection: toggleWorkspaceModelSelection, + setModelCount: setWorkspaceModelCount, + startRun: startWorkspaceRun, + } = useWorkspaceHome({ + activeWorkspace, + models, + selectedModelId, + addWorktreeAgent, + connectWorkspace, + startThreadForWorkspace, + sendUserMessageToThread, + }); + const { commitMessage, commitMessageLoading, @@ -1158,6 +1184,26 @@ function MainApp() { queueMessage, }); + const handleSelectWorkspaceInstance = useCallback( + (workspaceId: string, threadId: string) => { + exitDiffView(); + resetPullRequestSelection(); + selectWorkspace(workspaceId); + setActiveThreadId(threadId, workspaceId); + if (isCompact) { + setActiveTab("codex"); + } + }, + [ + exitDiffView, + isCompact, + resetPullRequestSelection, + selectWorkspace, + setActiveTab, + setActiveThreadId, + ], + ); + const orderValue = (entry: WorkspaceInfo) => typeof entry.settings.sortOrder === "number" ? entry.settings.sortOrder @@ -1208,9 +1254,9 @@ function MainApp() { ); }; - const showComposer = !isCompact + const showComposer = (!isCompact ? centerMode === "chat" || centerMode === "diff" - : (isTablet ? tabletTab : activeTab) === "codex"; + : (isTablet ? tabletTab : activeTab) === "codex") && !showWorkspaceHome; const showGitDetail = Boolean(selectedDiffPath) && isPhone; const { terminalTabs, @@ -1332,6 +1378,7 @@ function MainApp() { exitDiffView(); resetPullRequestSelection(); selectWorkspace(workspaceId); + setActiveThreadId(null, workspaceId); }, onConnectWorkspace: async (workspace) => { await connectWorkspace(workspace); @@ -1638,6 +1685,46 @@ function MainApp() { onWorkspaceDrop: handleWorkspaceDrop, }); + const workspaceHomeNode = activeWorkspace ? ( + openSettings("dictation")} + dictationError={dictationError} + onDismissDictationError={clearDictationError} + dictationHint={dictationHint} + onDismissDictationHint={clearDictationHint} + dictationTranscript={dictationTranscript} + onDictationTranscriptHandled={clearDictationTranscript} + /> + ) : null; + + const mainMessagesNode = showWorkspaceHome ? workspaceHomeNode : messagesNode; + const desktopTopbarLeftNodeWithToggle = !isCompact ? (
@@ -1696,7 +1783,7 @@ function MainApp() { hasActivePlan={hasActivePlan} activeWorkspace={Boolean(activeWorkspace)} sidebarNode={sidebarNode} - messagesNode={messagesNode} + messagesNode={mainMessagesNode} composerNode={composerNode} approvalToastsNode={approvalToastsNode} updateToastNode={updateToastNode} diff --git a/src/features/composer/components/ComposerInput.tsx b/src/features/composer/components/ComposerInput.tsx index 548c97456..ef32c1348 100644 --- a/src/features/composer/components/ComposerInput.tsx +++ b/src/features/composer/components/ComposerInput.tsx @@ -44,6 +44,7 @@ type ComposerInputProps = { highlightIndex: number; onHighlightIndex: (index: number) => void; onSelectSuggestion: (item: AutocompleteItem) => void; + suggestionsStyle?: React.CSSProperties; }; export function ComposerInput({ @@ -80,6 +81,7 @@ export function ComposerInput({ highlightIndex, onHighlightIndex, onSelectSuggestion, + suggestionsStyle, }: ComposerInputProps) { const suggestionListRef = useRef(null); const suggestionRefs = useRef>([]); @@ -275,6 +277,7 @@ export function ComposerInput({ className="composer-suggestions popover-surface" role="listbox" ref={suggestionListRef} + style={suggestionsStyle} > {suggestions.map((item, index) => (
- + {onToggleExpand && ( + + )} + + + {runModeOpen && ( +
+ + +
+ )} + + )} + +
+
+ + +
+ {modelsOpen && ( +
+ {models.length === 0 && ( +
+ Connect this workspace to load available models. +
+ )} + {models.map((model) => { + const isSelected = + runMode === "local" + ? model.id === selectedModelId + : Boolean(modelSelections[model.id]); + const count = modelSelections[model.id] ?? 1; + return ( +
+ + {runMode === "worktree" && ( + <> +
+ {count}x + +
+
+ {INSTANCE_OPTIONS.map((option) => ( + + ))} +
+ + )} +
+ ); + })} +
+ )} +
+ + +
+
+
Recent runs
+
+ {runs.length === 0 ? ( +
+ Start a run to see its instances tracked here. +
+ ) : ( +
+ {runs.map((run) => { + const hasInstances = run.instances.length > 0; + const labelCounts = new Map(); + run.instances.forEach((instance) => { + labelCounts.set( + instance.modelLabel, + (labelCounts.get(instance.modelLabel) ?? 0) + 1, + ); + }); + return ( +
+
+
+
{run.title}
+
+ {run.mode === "local" ? "Local" : "Worktree"} ·{" "} + {run.instances.length} instance + {run.instances.length === 1 ? "" : "s"} + {run.status === "failed" && " · Failed"} + {run.status === "partial" && " · Partial"} +
+
+
+ {formatRelativeTime(run.createdAt)} +
+
+ {run.error && ( +
{run.error}
+ )} + {run.instanceErrors.length > 0 && ( +
+ {run.instanceErrors.slice(0, 2).map((entry, index) => ( +
+ {entry.message} +
+ ))} + {run.instanceErrors.length > 2 && ( +
+ +{run.instanceErrors.length - 2} more +
+ )} +
+ )} + {hasInstances ? ( +
+ {run.instances.map((instance) => { + const status = threadStatusById[instance.threadId]; + const statusLabel = status?.isProcessing + ? "Running" + : status?.isReviewing + ? "Reviewing" + : "Idle"; + const stateClass = status?.isProcessing + ? "is-running" + : status?.isReviewing + ? "is-reviewing" + : "is-idle"; + const isActive = + instance.threadId === activeThreadId && + instance.workspaceId === activeWorkspaceId; + const totalForLabel = labelCounts.get(instance.modelLabel) ?? 1; + const label = + totalForLabel > 1 + ? `${instance.modelLabel} ${instance.sequence}` + : instance.modelLabel; + return ( + + ); + })} +
+ ) : run.status === "failed" ? ( +
+ No instances were started. +
+ ) : ( +
+ + + Instances are preparing... + +
+ )} +
+ ); + })} +
+ )} +
+ + ); +} diff --git a/src/features/workspaces/hooks/useWorkspaceHome.test.tsx b/src/features/workspaces/hooks/useWorkspaceHome.test.tsx new file mode 100644 index 000000000..3b4017229 --- /dev/null +++ b/src/features/workspaces/hooks/useWorkspaceHome.test.tsx @@ -0,0 +1,285 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ModelOption, WorkspaceInfo } from "../../../types"; +import { generateRunMetadata } from "../../../services/tauri"; +import { useWorkspaceHome } from "./useWorkspaceHome"; + +vi.mock("../../../services/tauri", () => ({ + generateRunMetadata: vi.fn(), +})); + +const workspace: WorkspaceInfo = { + id: "ws-1", + name: "Project", + path: "/tmp/project", + connected: true, + kind: "main", + settings: { sidebarCollapsed: false }, +}; + +const worktreeWorkspace: WorkspaceInfo = { + id: "wt-1", + name: "feat/test", + path: "/tmp/project/worktrees/feat-test", + connected: true, + kind: "worktree", + parentId: "ws-1", + worktree: { branch: "feat/test" }, + settings: { sidebarCollapsed: false }, +}; + +const models: ModelOption[] = [ + { + id: "id-1", + model: "gpt-5.1-max", + displayName: "GPT-5.1 Max", + description: "Test model", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "Low effort" }, + { reasoningEffort: "medium", description: "Medium effort" }, + { reasoningEffort: "high", description: "High effort" }, + ], + defaultReasoningEffort: "medium", + isDefault: false, + }, +]; + +describe("useWorkspaceHome", () => { + it("uses provider model name for worktree runs", async () => { + const addWorktreeAgent = vi.fn().mockResolvedValue(worktreeWorkspace); + const connectWorkspace = vi.fn().mockResolvedValue(undefined); + const startThreadForWorkspace = vi.fn().mockResolvedValue("thread-1"); + const sendUserMessageToThread = vi.fn().mockResolvedValue(undefined); + vi.mocked(generateRunMetadata).mockResolvedValue({ + title: "Test run", + worktreeName: "feat/test", + }); + + const { result } = renderHook(() => + useWorkspaceHome({ + activeWorkspace: workspace, + models, + selectedModelId: null, + addWorktreeAgent, + connectWorkspace, + startThreadForWorkspace, + sendUserMessageToThread, + }), + ); + + act(() => { + result.current.setRunMode("worktree"); + result.current.toggleModelSelection("id-1"); + result.current.setDraft("Hello worktree"); + }); + + await act(async () => { + await result.current.startRun(); + }); + + expect(sendUserMessageToThread).toHaveBeenCalledWith( + worktreeWorkspace, + "thread-1", + "Hello worktree", + [], + expect.objectContaining({ model: "gpt-5.1-max" }), + ); + }); + + it("allows image-only local runs", async () => { + const addWorktreeAgent = vi.fn(); + const connectWorkspace = vi.fn().mockResolvedValue(undefined); + const startThreadForWorkspace = vi.fn().mockResolvedValue("thread-1"); + const sendUserMessageToThread = vi.fn().mockResolvedValue(undefined); + vi.mocked(generateRunMetadata).mockResolvedValue({ + title: "Image run", + worktreeName: "feat/image", + }); + + const { result } = renderHook(() => + useWorkspaceHome({ + activeWorkspace: workspace, + models, + selectedModelId: "id-1", + addWorktreeAgent, + connectWorkspace, + startThreadForWorkspace, + sendUserMessageToThread, + }), + ); + + await act(async () => { + const started = await result.current.startRun(["img-1"]); + expect(started).toBe(true); + }); + + expect(sendUserMessageToThread).toHaveBeenCalledWith( + workspace, + "thread-1", + "", + ["img-1"], + expect.objectContaining({ model: "gpt-5.1-max" }), + ); + }); + + it("blocks worktree runs without model selections", async () => { + const addWorktreeAgent = vi.fn(); + const connectWorkspace = vi.fn(); + const startThreadForWorkspace = vi.fn(); + const sendUserMessageToThread = vi.fn(); + vi.mocked(generateRunMetadata).mockResolvedValue({ + title: "Blocked", + worktreeName: "feat/blocked", + }); + + const { result } = renderHook(() => + useWorkspaceHome({ + activeWorkspace: workspace, + models, + selectedModelId: null, + addWorktreeAgent, + connectWorkspace, + startThreadForWorkspace, + sendUserMessageToThread, + }), + ); + + act(() => { + result.current.setRunMode("worktree"); + result.current.setDraft("Hello"); + }); + + let started = true; + await act(async () => { + started = await result.current.startRun(); + }); + + expect(started).toBe(false); + expect(result.current.error).toBe( + "Select at least one model to run in a worktree.", + ); + expect(result.current.runs).toHaveLength(0); + }); + + it("captures partial failures for multi-instance worktree runs", async () => { + const addWorktreeAgent = vi + .fn() + .mockResolvedValueOnce(worktreeWorkspace) + .mockResolvedValueOnce(null); + const connectWorkspace = vi.fn().mockResolvedValue(undefined); + const startThreadForWorkspace = vi.fn().mockResolvedValue("thread-1"); + const sendUserMessageToThread = vi.fn().mockResolvedValue(undefined); + vi.mocked(generateRunMetadata).mockResolvedValue({ + title: "Partial", + worktreeName: "feat/partial", + }); + + const { result } = renderHook(() => + useWorkspaceHome({ + activeWorkspace: workspace, + models, + selectedModelId: null, + addWorktreeAgent, + connectWorkspace, + startThreadForWorkspace, + sendUserMessageToThread, + }), + ); + + act(() => { + result.current.setRunMode("worktree"); + result.current.toggleModelSelection("id-1"); + result.current.setModelCount("id-1", 2); + result.current.setDraft("Hello"); + }); + + await act(async () => { + await result.current.startRun(); + }); + + expect(result.current.runs[0].status).toBe("partial"); + expect(result.current.runs[0].instanceErrors.length).toBeGreaterThan(0); + }); + + it("updates title after metadata resolves for local runs", async () => { + const addWorktreeAgent = vi.fn(); + const connectWorkspace = vi.fn().mockResolvedValue(undefined); + const startThreadForWorkspace = vi.fn().mockResolvedValue("thread-1"); + const sendUserMessageToThread = vi.fn().mockResolvedValue(undefined); + let resolveMetadata: (value: { title: string; worktreeName: string }) => void = + () => {}; + vi.mocked(generateRunMetadata).mockReturnValue( + new Promise((resolve) => { + resolveMetadata = resolve; + }), + ); + + const { result } = renderHook(() => + useWorkspaceHome({ + activeWorkspace: workspace, + models, + selectedModelId: "id-1", + addWorktreeAgent, + connectWorkspace, + startThreadForWorkspace, + sendUserMessageToThread, + }), + ); + + act(() => { + result.current.setDraft("Local prompt"); + }); + + await act(async () => { + await result.current.startRun(); + }); + + expect(result.current.runs[0].title).toBe("Local prompt"); + + await act(async () => { + resolveMetadata({ title: "Meta title", worktreeName: "feat/meta" }); + await Promise.resolve(); + }); + + expect(result.current.runs[0].title).toBe("Meta title"); + }); + + it("keeps attachments when worktree selection is missing", async () => { + const addWorktreeAgent = vi.fn(); + const connectWorkspace = vi.fn(); + const startThreadForWorkspace = vi.fn(); + const sendUserMessageToThread = vi.fn(); + vi.mocked(generateRunMetadata).mockResolvedValue({ + title: "Blocked", + worktreeName: "feat/blocked", + }); + + const { result } = renderHook(() => + useWorkspaceHome({ + activeWorkspace: workspace, + models, + selectedModelId: null, + addWorktreeAgent, + connectWorkspace, + startThreadForWorkspace, + sendUserMessageToThread, + }), + ); + + act(() => { + result.current.setRunMode("worktree"); + }); + + let started = true; + await act(async () => { + started = await result.current.startRun(["img-1"]); + }); + + expect(started).toBe(false); + expect(result.current.runs).toHaveLength(0); + expect(result.current.error).toBe( + "Select at least one model to run in a worktree.", + ); + }); +}); diff --git a/src/features/workspaces/hooks/useWorkspaceHome.ts b/src/features/workspaces/hooks/useWorkspaceHome.ts new file mode 100644 index 000000000..22734a25a --- /dev/null +++ b/src/features/workspaces/hooks/useWorkspaceHome.ts @@ -0,0 +1,604 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ModelOption, WorkspaceInfo } from "../../../types"; +import { generateRunMetadata } from "../../../services/tauri"; + +export type WorkspaceRunMode = "local" | "worktree"; + +export type WorkspaceHomeRunInstance = { + id: string; + workspaceId: string; + threadId: string; + modelId: string | null; + modelLabel: string; + sequence: number; +}; + +export type WorkspaceHomeRun = { + id: string; + workspaceId: string; + title: string; + prompt: string; + createdAt: number; + mode: WorkspaceRunMode; + instances: WorkspaceHomeRunInstance[]; + status: "pending" | "ready" | "partial" | "failed"; + error: string | null; + instanceErrors: Array<{ message: string }>; +}; + +type UseWorkspaceHomeOptions = { + activeWorkspace: WorkspaceInfo | null; + models: ModelOption[]; + selectedModelId: string | null; + addWorktreeAgent: ( + workspace: WorkspaceInfo, + branch: string, + options?: { activate?: boolean }, + ) => Promise; + connectWorkspace: (workspace: WorkspaceInfo) => Promise; + startThreadForWorkspace: ( + workspaceId: string, + options?: { activate?: boolean }, + ) => Promise; + sendUserMessageToThread: ( + workspace: WorkspaceInfo, + threadId: string, + text: string, + images?: string[], + options?: { model?: string | null; effort?: string | null }, + ) => Promise; +}; + +type WorkspaceHomeState = { + runsByWorkspace: Record; + draftsByWorkspace: Record; + modeByWorkspace: Record; + modelSelectionsByWorkspace: Record>; + errorByWorkspace: Record; + submittingByWorkspace: Record; +}; + +const DEFAULT_MODE: WorkspaceRunMode = "local"; +const EMPTY_SELECTIONS: Record = {}; +const MAX_TITLE_LENGTH = 56; + +const createRunId = () => + `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +const buildRunTitle = (prompt: string) => { + const firstLine = prompt.trim().split("\n")[0] ?? ""; + const normalized = firstLine.replace(/\s+/g, " ").trim(); + if (!normalized) { + return "New run"; + } + if (normalized.length > MAX_TITLE_LENGTH) { + return `${normalized.slice(0, MAX_TITLE_LENGTH)}...`; + } + return normalized; +}; + +const ALLOWED_PREFIXES = [ + "feat", + "fix", + "chore", + "test", + "docs", + "refactor", + "perf", + "build", + "ci", + "style", +]; + +const PREFIX_RULES: Array<{ prefix: string; keywords: string[] }> = [ + { prefix: "test", keywords: ["test", "tests", "testing"] }, + { prefix: "docs", keywords: ["doc", "docs", "documentation", "readme"] }, + { prefix: "chore", keywords: ["chore", "cleanup", "maintenance"] }, + { prefix: "refactor", keywords: ["refactor"] }, + { prefix: "perf", keywords: ["perf", "performance", "optimize", "optimization"] }, + { prefix: "build", keywords: ["build", "bundle", "compile"] }, + { prefix: "ci", keywords: ["ci", "pipeline", "workflow"] }, + { prefix: "style", keywords: ["style", "format", "lint"] }, + { + prefix: "fix", + keywords: [ + "fix", + "bug", + "error", + "issue", + "broken", + "regression", + "crash", + "failure", + ], + }, +]; + +const resolveWorktreePrefix = (prompt: string) => { + const lower = prompt.toLowerCase(); + const matched = PREFIX_RULES.find((rule) => + rule.keywords.some((keyword) => lower.includes(keyword)), + ); + return matched?.prefix ?? "feat"; +}; + +const buildWorktreeBranch = (prompt: string) => { + const prefix = resolveWorktreePrefix(prompt); + const base = prompt + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, " ") + .trim() + .split(/\s+/) + .slice(0, 4) + .join("-"); + const slug = base || `run-${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}/${slug}`; +}; + +const resolveModelLabel = (model: ModelOption | null, fallback: string) => + model?.displayName?.trim() || model?.model?.trim() || fallback; + +const normalizeWorktreeName = (value: string | null | undefined) => { + if (!value) { + return null; + } + const trimmed = value.trim().toLowerCase(); + for (const prefix of ALLOWED_PREFIXES) { + const prefixWithSlash = `${prefix}/`; + if (trimmed.startsWith(prefixWithSlash)) { + const remainder = trimmed.slice(prefixWithSlash.length).replace(/^\/+/, ""); + return remainder ? `${prefixWithSlash}${remainder}` : null; + } + } + for (const prefix of ALLOWED_PREFIXES) { + const dashPrefix = `${prefix}-`; + if (trimmed.startsWith(dashPrefix)) { + const remainder = trimmed.slice(dashPrefix.length).replace(/^\/+/, ""); + return remainder ? `${prefix}/${remainder}` : null; + } + } + const fallback = trimmed.replace(/^\/+/, ""); + return fallback ? `feat/${fallback}` : null; +}; + +export function useWorkspaceHome({ + activeWorkspace, + models, + selectedModelId, + addWorktreeAgent, + connectWorkspace, + startThreadForWorkspace, + sendUserMessageToThread, +}: UseWorkspaceHomeOptions) { + const [state, setState] = useState({ + runsByWorkspace: {}, + draftsByWorkspace: {}, + modeByWorkspace: {}, + modelSelectionsByWorkspace: {}, + errorByWorkspace: {}, + submittingByWorkspace: {}, + }); + + const activeWorkspaceId = activeWorkspace?.id ?? null; + const runs = activeWorkspaceId ? state.runsByWorkspace[activeWorkspaceId] ?? [] : []; + const draft = activeWorkspaceId ? state.draftsByWorkspace[activeWorkspaceId] ?? "" : ""; + const runMode = activeWorkspaceId + ? state.modeByWorkspace[activeWorkspaceId] ?? DEFAULT_MODE + : DEFAULT_MODE; + const modelSelections = useMemo(() => { + if (!activeWorkspaceId) { + return EMPTY_SELECTIONS; + } + return state.modelSelectionsByWorkspace[activeWorkspaceId] ?? EMPTY_SELECTIONS; + }, [activeWorkspaceId, state.modelSelectionsByWorkspace]); + const error = activeWorkspaceId ? state.errorByWorkspace[activeWorkspaceId] ?? null : null; + const isSubmitting = activeWorkspaceId + ? state.submittingByWorkspace[activeWorkspaceId] ?? false + : false; + + useEffect(() => { + if (!activeWorkspaceId || !activeWorkspace) { + return; + } + if ((activeWorkspace.kind ?? "main") === "worktree" && runMode !== "local") { + setState((prev) => ({ + ...prev, + modeByWorkspace: { ...prev.modeByWorkspace, [activeWorkspaceId]: "local" }, + })); + } + }, [activeWorkspace, activeWorkspaceId, runMode]); + + const modelLookup = useMemo(() => { + const map = new Map(); + models.forEach((model) => { + map.set(model.id, model); + }); + return map; + }, [models]); + + const setDraft = useCallback( + (value: string) => { + if (!activeWorkspaceId) { + return; + } + setState((prev) => ({ + ...prev, + draftsByWorkspace: { ...prev.draftsByWorkspace, [activeWorkspaceId]: value }, + errorByWorkspace: { ...prev.errorByWorkspace, [activeWorkspaceId]: null }, + })); + }, + [activeWorkspaceId], + ); + + const setRunMode = useCallback( + (mode: WorkspaceRunMode) => { + if (!activeWorkspaceId) { + return; + } + setState((prev) => ({ + ...prev, + modeByWorkspace: { ...prev.modeByWorkspace, [activeWorkspaceId]: mode }, + errorByWorkspace: { ...prev.errorByWorkspace, [activeWorkspaceId]: null }, + })); + }, + [activeWorkspaceId], + ); + + const toggleModelSelection = useCallback( + (modelId: string) => { + if (!activeWorkspaceId) { + return; + } + setState((prev) => { + const current = prev.modelSelectionsByWorkspace[activeWorkspaceId] ?? {}; + const next = { ...current }; + if (next[modelId]) { + delete next[modelId]; + } else { + next[modelId] = 1; + } + return { + ...prev, + modelSelectionsByWorkspace: { + ...prev.modelSelectionsByWorkspace, + [activeWorkspaceId]: next, + }, + errorByWorkspace: { ...prev.errorByWorkspace, [activeWorkspaceId]: null }, + }; + }); + }, + [activeWorkspaceId], + ); + + const setModelCount = useCallback( + (modelId: string, count: number) => { + if (!activeWorkspaceId) { + return; + } + setState((prev) => { + const current = prev.modelSelectionsByWorkspace[activeWorkspaceId] ?? {}; + const next = { ...current, [modelId]: Math.max(1, count) }; + return { + ...prev, + modelSelectionsByWorkspace: { + ...prev.modelSelectionsByWorkspace, + [activeWorkspaceId]: next, + }, + errorByWorkspace: { ...prev.errorByWorkspace, [activeWorkspaceId]: null }, + }; + }); + }, + [activeWorkspaceId], + ); + + const setWorkspaceError = useCallback( + (message: string | null) => { + if (!activeWorkspaceId) { + return; + } + setState((prev) => ({ + ...prev, + errorByWorkspace: { ...prev.errorByWorkspace, [activeWorkspaceId]: message }, + })); + }, + [activeWorkspaceId], + ); + + const setSubmitting = useCallback( + (value: boolean) => { + if (!activeWorkspaceId) { + return; + } + setState((prev) => ({ + ...prev, + submittingByWorkspace: { + ...prev.submittingByWorkspace, + [activeWorkspaceId]: value, + }, + })); + }, + [activeWorkspaceId], + ); + + const updateRunState = useCallback( + ( + workspaceId: string, + runId: string, + updates: Partial, + ) => { + setState((prev) => { + const runsForWorkspace = prev.runsByWorkspace[workspaceId] ?? []; + return { + ...prev, + runsByWorkspace: { + ...prev.runsByWorkspace, + [workspaceId]: runsForWorkspace.map((run) => + run.id === runId ? { ...run, ...updates } : run, + ), + }, + }; + }); + }, + [], + ); + + const updateRunTitle = useCallback( + (workspaceId: string, runId: string, title: string) => { + setState((prev) => { + const runsForWorkspace = prev.runsByWorkspace[workspaceId] ?? []; + return { + ...prev, + runsByWorkspace: { + ...prev.runsByWorkspace, + [workspaceId]: runsForWorkspace.map((run) => + run.id === runId ? { ...run, title } : run, + ), + }, + }; + }); + }, + [], + ); + + const startRun = useCallback(async (images: string[] = []) => { + if (!activeWorkspaceId || !activeWorkspace) { + return false; + } + const prompt = draft.trim(); + const hasImages = images.length > 0; + if ((!prompt && !hasImages) || isSubmitting) { + return false; + } + + const selectedModels = Object.entries(modelSelections) + .filter(([modelId, count]) => count > 0 && modelLookup.has(modelId)) + .map(([modelId, count]) => ({ + modelId, + count, + model: modelLookup.get(modelId) ?? null, + })); + + if (runMode === "worktree" && selectedModels.length === 0) { + setWorkspaceError("Select at least one model to run in a worktree."); + return false; + } + + setSubmitting(true); + setWorkspaceError(null); + + const runId = createRunId(); + const runIdParts = runId.split("-"); + const runSuffix = runIdParts.length + ? runIdParts[runIdParts.length - 1] + : runId.slice(-6); + const fallbackTitle = buildRunTitle(prompt); + const run: WorkspaceHomeRun = { + id: runId, + workspaceId: activeWorkspaceId, + title: fallbackTitle, + prompt, + createdAt: Date.now(), + mode: runMode, + instances: [], + status: "pending", + error: null, + instanceErrors: [], + }; + + setState((prev) => ({ + ...prev, + runsByWorkspace: { + ...prev.runsByWorkspace, + [activeWorkspaceId]: [run, ...(prev.runsByWorkspace[activeWorkspaceId] ?? [])], + }, + draftsByWorkspace: { ...prev.draftsByWorkspace, [activeWorkspaceId]: "" }, + })); + + let worktreeBaseName: string | null = null; + if (runMode === "local") { + void generateRunMetadata(activeWorkspace.id, prompt) + .then((metadata) => { + if (!metadata?.title) { + return; + } + const nextTitle = metadata.title.trim(); + if (nextTitle && nextTitle !== fallbackTitle) { + updateRunTitle(activeWorkspaceId, runId, nextTitle); + } + }) + .catch(() => { + // Metadata is best-effort for local runs. + }); + } else { + try { + const metadata = await generateRunMetadata(activeWorkspace.id, prompt); + if (metadata?.title && metadata.title.trim() !== fallbackTitle) { + updateRunTitle(activeWorkspaceId, runId, metadata.title.trim()); + } + worktreeBaseName = normalizeWorktreeName(metadata?.worktreeName) ?? null; + } catch { + // Best-effort fallback to local naming. + } + if (!worktreeBaseName) { + worktreeBaseName = buildWorktreeBranch(prompt); + } + } + const worktreeSlugBase = worktreeBaseName + ? `${worktreeBaseName}-${runSuffix}` + : null; + + const instances: WorkspaceHomeRunInstance[] = []; + let runError: string | null = null; + const instanceErrors: Array<{ message: string }> = []; + try { + if (runMode === "local") { + try { + if (!activeWorkspace.connected) { + await connectWorkspace(activeWorkspace); + } + const threadId = await startThreadForWorkspace(activeWorkspace.id, { + activate: false, + }); + if (!threadId) { + throw new Error("Failed to start a local thread."); + } + const localModel = selectedModelId + ? modelLookup.get(selectedModelId)?.model ?? null + : null; + await sendUserMessageToThread(activeWorkspace, threadId, prompt, images, { + model: localModel, + }); + const model = + selectedModelId ? modelLookup.get(selectedModelId) ?? null : null; + instances.push({ + id: `${runId}-local-1`, + workspaceId: activeWorkspace.id, + threadId, + modelId: selectedModelId ?? null, + modelLabel: resolveModelLabel(model, "Default model"), + sequence: 1, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + runError = message; + instanceErrors.push({ message }); + } + } else { + let instanceCounter = 0; + let failureCount = 0; + const totalInstanceCount = selectedModels.reduce( + (sum, selection) => sum + selection.count, + 0, + ); + const branchBaseFallback = worktreeSlugBase ?? buildWorktreeBranch(prompt); + for (const selection of selectedModels) { + const label = resolveModelLabel(selection.model, selection.modelId); + for (let index = 0; index < selection.count; index += 1) { + instanceCounter += 1; + const instanceSuffix = + totalInstanceCount > 1 ? `-${instanceCounter}` : ""; + const branch = `${branchBaseFallback}${instanceSuffix}`; + try { + const worktreeWorkspace = await addWorktreeAgent( + activeWorkspace, + branch, + { activate: false }, + ); + if (!worktreeWorkspace) { + throw new Error("Failed to create worktree."); + } + if (!worktreeWorkspace.connected) { + await connectWorkspace(worktreeWorkspace); + } + const threadId = await startThreadForWorkspace(worktreeWorkspace.id, { + activate: false, + }); + if (!threadId) { + throw new Error("Failed to start a worktree thread."); + } + await sendUserMessageToThread( + worktreeWorkspace, + threadId, + prompt, + images, + { + model: selection.model?.model ?? selection.modelId, + effort: null, + }, + ); + instances.push({ + id: `${runId}-${selection.modelId}-${index + 1}`, + workspaceId: worktreeWorkspace.id, + threadId, + modelId: selection.modelId, + modelLabel: label, + sequence: index + 1, + }); + } catch (error) { + failureCount += 1; + const message = error instanceof Error ? error.message : String(error); + runError ??= message; + instanceErrors.push({ message }); + } + } + } + if (failureCount > 0) { + runError = `Started ${instances.length}/${totalInstanceCount} runs. ${failureCount} failed.`; + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + runError ??= message; + } finally { + let status: WorkspaceHomeRun["status"] = "ready"; + if (instances.length === 0) { + runError ??= "Failed to start any instances."; + status = "failed"; + } else if (runError) { + status = "partial"; + } + updateRunState(activeWorkspaceId, runId, { + instances, + status, + error: runError, + instanceErrors, + }); + if (runError && status === "failed") { + setWorkspaceError(runError); + } + setSubmitting(false); + } + return true; + }, [ + activeWorkspace, + activeWorkspaceId, + addWorktreeAgent, + connectWorkspace, + draft, + isSubmitting, + modelLookup, + modelSelections, + updateRunState, + runMode, + selectedModelId, + sendUserMessageToThread, + setSubmitting, + setWorkspaceError, + startThreadForWorkspace, + updateRunTitle, + ]); + + return { + runs, + draft, + runMode, + modelSelections, + error, + isSubmitting, + setDraft, + setRunMode, + toggleModelSelection, + setModelCount, + startRun, + }; +} diff --git a/src/features/workspaces/hooks/useWorkspaces.ts b/src/features/workspaces/hooks/useWorkspaces.ts index d58162b1f..8b8e9bd5f 100644 --- a/src/features/workspaces/hooks/useWorkspaces.ts +++ b/src/features/workspaces/hooks/useWorkspaces.ts @@ -270,7 +270,11 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { return checks.filter((entry) => entry.isDir).map((entry) => entry.path); }, []); - async function addWorktreeAgent(parent: WorkspaceInfo, branch: string) { + async function addWorktreeAgent( + parent: WorkspaceInfo, + branch: string, + options?: { activate?: boolean }, + ) { const trimmed = branch.trim(); if (!trimmed) { return null; @@ -285,7 +289,9 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { try { const workspace = await addWorktreeService(parent.id, trimmed); setWorkspaces((prev) => [...prev, workspace]); - setActiveWorkspaceId(workspace.id); + if (options?.activate !== false) { + setActiveWorkspaceId(workspace.id); + } Sentry.metrics.count("worktree_agent_created", 1, { attributes: { workspace_id: workspace.id, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 2b7c93437..c131136c2 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -328,6 +328,13 @@ export async function getModelList(workspaceId: string) { return invoke("model_list", { workspaceId }); } +export async function generateRunMetadata(workspaceId: string, prompt: string) { + return invoke<{ title: string; worktreeName: string }>("generate_run_metadata", { + workspaceId, + prompt, + }); +} + export async function getCollaborationModes(workspaceId: string) { return invoke("collaboration_mode_list", { workspaceId }); } diff --git a/src/styles/composer.css b/src/styles/composer.css index ed6bc19d8..fe0a8f7a8 100644 --- a/src/styles/composer.css +++ b/src/styles/composer.css @@ -164,6 +164,16 @@ z-index: 20; } +.workspace-home .composer-attachment:hover .composer-attachment-preview, +.workspace-home .composer-attachment:focus-within .composer-attachment-preview { + transform: translate(-50%, 6px) scale(1); +} + +.workspace-home .composer-attachment-preview { + top: calc(100% + 8px); + bottom: auto; +} + .composer-attachment-preview img { display: block; width: 100%; diff --git a/src/styles/main.css b/src/styles/main.css index 77c6c28dd..13f179fc9 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -115,6 +115,9 @@ .open-app-button .open-app-action, .open-app-button .open-app-toggle { + display: flex; + justify-content: center; + align-items: center; border: none; background: transparent; box-shadow: none; diff --git a/src/styles/workspace-home.css b/src/styles/workspace-home.css new file mode 100644 index 000000000..a89cccf6f --- /dev/null +++ b/src/styles/workspace-home.css @@ -0,0 +1,378 @@ +.workspace-home { + height: 100%; + width: 100%; + grid-column: 1 / -1; + grid-row: 1 / -1; + display: flex; + flex-direction: column; + gap: 20px; + justify-content: flex-start; + max-width: 860px; + margin: 0 auto; + text-align: left; + padding: 36px 32px 24px; + overflow-y: auto; +} + +.workspace-home-hero { + display: flex; + align-items: center; + gap: 16px; +} + +.workspace-home-icon { + width: 48px; + height: 48px; + border-radius: 12px; + border: 1px solid var(--border-subtle); + background: var(--surface-card); + object-fit: cover; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.18); +} + +.workspace-home-title { + font-size: 34px; + font-weight: 600; + letter-spacing: -0.02em; +} + +.workspace-home-path { + font-size: 12px; + color: var(--text-muted); + word-break: break-all; +} + +.workspace-home-composer { + display: flex; + flex-direction: column; + gap: 10px; +} + +.composer { + border-radius: 10px; +} + +.workspace-home .composer-input { + position: relative; +} + +.workspace-home-error { + font-size: 12px; + color: var(--text-danger); + background: rgba(236, 72, 153, 0.08); + border-radius: 10px; + padding: 8px 10px; + border: 1px solid rgba(236, 72, 153, 0.2); +} + +.workspace-home-section-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.workspace-home-section-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); +} + +.workspace-home-section-meta { + font-size: 12px; + color: var(--text-muted); +} + +.workspace-home-controls { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.workspace-home-control .open-app-button { + border-color: var(--border-muted); + background: var(--surface-card); +} + +.workspace-home-dropdown { + left: 0; +} + +.workspace-home-mode-icon { + width: 14px; + height: 14px; +} + +.workspace-home-mode-note { + font-size: 13px; + color: var(--text-muted); +} + +.workspace-home-model-dropdown { + width: 190px !important; +} + +.workspace-home-model-option { + position: relative; + display: flex; + align-items: center; + border-radius: 8px; +} + +.workspace-home-model-option .workspace-home-model-toggle { + width: 175px; + flex: 1; + justify-content: space-between; +} + +.workspace-home-model-option.is-active .workspace-home-model-toggle, +.workspace-home-model-toggle.is-active { + background: var(--surface-hover); + color: var(--text-stronger); +} + +.workspace-home-model-meta { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--text-faint); + pointer-events: none; + user-select: none; +} + +.workspace-home-model-submenu { + position: absolute; + top: 0; + left: calc(100% + 6px); + min-width: 80px; + padding: 6px; + border-radius: 10px; + background: var(--surface-popover); + border: 1px solid var(--border-muted); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.3); + opacity: 0; + pointer-events: none; + transform: translateX(-4px); + transition: opacity 120ms ease, transform 120ms ease; + z-index: 6; +} + +.workspace-home-model-submenu::before { + content: ""; + position: absolute; + left: -8px; + top: 0; + width: 8px; + height: 100%; +} + +.workspace-home-model-option:hover .workspace-home-model-submenu, +.workspace-home-model-option:focus-within .workspace-home-model-submenu { + opacity: 1; + pointer-events: auto; + transform: translateX(0); +} + +.workspace-home-model-submenu-item { + width: 100%; + border: none; + background: transparent; + color: var(--text-muted); + font-size: 12px; + padding: 6px 8px; + text-align: left; + border-radius: 8px; + cursor: pointer; +} + +.workspace-home-model-submenu-item:hover, +.workspace-home-model-submenu-item.is-active { + background: var(--surface-hover); + color: var(--text-stronger); +} + +.workspace-home-runs { + display: flex; + flex-direction: column; + gap: 12px; +} + +.workspace-home-run-grid { + display: flex; + flex-direction: column; + gap: 12px; +} + +.workspace-home-run-card { + padding: 14px 16px; + border-radius: 14px; + background: var(--surface-card); + border: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 10px 22px rgba(0, 0, 0, 0.2); +} + +.workspace-home-run-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.workspace-home-run-title { + font-size: 16px; + font-weight: 600; + color: var(--text-strong); +} + +.workspace-home-run-meta { + font-size: 12px; + color: var(--text-muted); +} + +.workspace-home-run-error { + font-size: 12px; + color: var(--text-danger); + background: rgba(236, 72, 153, 0.08); + border-radius: 10px; + padding: 6px 10px; + border: 1px solid rgba(236, 72, 153, 0.2); +} + +.workspace-home-run-error-list { + display: grid; + gap: 4px; + font-size: 11px; + color: var(--text-muted); +} + +.workspace-home-run-error-item { + background: rgba(255, 255, 255, 0.04); + border-radius: 8px; + padding: 4px 8px; + border: 1px solid var(--border-muted); +} + +.workspace-home-run-time { + font-size: 12px; + color: var(--text-faint); + white-space: nowrap; +} + +.workspace-home-instance-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.workspace-home-instance { + border-radius: 12px; + border: 1px solid var(--border-muted); + background: var(--surface-item); + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 6px; + color: var(--text-strong); + cursor: pointer; + text-align: left; +} + +.workspace-home-instance.is-running { + border-color: rgba(34, 211, 238, 0.9); + box-shadow: 0 0 0 1px rgba(34, 211, 238, 0.15); + animation: workspace-home-instance-pulse 1.6s ease-in-out infinite; +} + +.workspace-home-instance.is-reviewing { + border-color: rgba(248, 113, 113, 0.9); + box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.18); + animation: workspace-home-instance-pulse 1.6s ease-in-out infinite; +} + +.workspace-home-instance.is-idle { + border-color: var(--border-muted); +} + +.workspace-home-instance.is-active { + border-color: rgba(98, 176, 255, 0.6); + box-shadow: 0 10px 18px rgba(21, 94, 117, 0.28); +} + +.workspace-home-instance-title { + font-size: 13px; + font-weight: 600; +} + +.workspace-home-instance-status { + font-size: 11px; + color: var(--text-muted); +} + +.workspace-home-instance-status.is-running { + color: var(--text-accent); +} + +@keyframes workspace-home-instance-pulse { + 0%, + 100% { + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08); + } + 50% { + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2); + } +} + +.workspace-home-empty { + font-size: 13px; + color: var(--text-muted); + padding: 12px; + border-radius: 12px; + border: 1px dashed var(--border-subtle); + background: var(--surface-quiet); +} + +.workspace-home-pending { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.workspace-home-pending .working-spinner { + width: 12px; + height: 12px; + border-width: 2px; +} + +.workspace-home-pending-text { + animation: workspace-home-pulse 1.4s ease-in-out infinite; +} + +@keyframes workspace-home-pulse { + 0%, + 100% { + opacity: 0.45; + } + 50% { + opacity: 1; + } +} + +@media (max-width: 720px) { + .workspace-home { + padding: 24px 20px; + } + + .workspace-home-title { + font-size: 28px; + } +} diff --git a/src/test/vitest.setup.ts b/src/test/vitest.setup.ts index f87eb26e6..ad5c50f3f 100644 --- a/src/test/vitest.setup.ts +++ b/src/test/vitest.setup.ts @@ -58,7 +58,12 @@ if (!("requestAnimationFrame" in globalThis)) { }); } -if (!("localStorage" in globalThis)) { +const hasLocalStorage = "localStorage" in globalThis; +const existingLocalStorage = hasLocalStorage + ? (globalThis as { localStorage?: Storage }).localStorage + : null; + +if (!existingLocalStorage || typeof existingLocalStorage.clear !== "function") { const store = new Map(); const localStorage = { getItem: (key: string) => (store.has(key) ? store.get(key) ?? null : null), @@ -76,5 +81,9 @@ if (!("localStorage" in globalThis)) { return store.size; }, }; - Object.defineProperty(globalThis, "localStorage", { value: localStorage }); + Object.defineProperty(globalThis, "localStorage", { + value: localStorage, + writable: true, + configurable: true, + }); } diff --git a/src/utils/caretPosition.ts b/src/utils/caretPosition.ts new file mode 100644 index 000000000..b1b45a898 --- /dev/null +++ b/src/utils/caretPosition.ts @@ -0,0 +1,71 @@ +type CaretPosition = { + top: number; + left: number; + lineHeight: number; +}; + +const CARET_STYLE_PROPS = [ + "direction", + "boxSizing", + "width", + "height", + "overflowX", + "overflowY", + "borderTopWidth", + "borderRightWidth", + "borderBottomWidth", + "borderLeftWidth", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "fontStyle", + "fontVariant", + "fontWeight", + "fontStretch", + "fontSize", + "fontFamily", + "lineHeight", + "textAlign", + "textTransform", + "textIndent", + "letterSpacing", + "wordSpacing", +] as const; + +export const getCaretPosition = ( + textarea: HTMLTextAreaElement, + position: number, +): CaretPosition | null => { + const style = window.getComputedStyle(textarea); + const mirror = document.createElement("div"); + mirror.style.position = "absolute"; + mirror.style.visibility = "hidden"; + mirror.style.pointerEvents = "none"; + mirror.style.whiteSpace = "pre-wrap"; + mirror.style.wordBreak = "break-word"; + mirror.style.left = "-9999px"; + mirror.style.top = "0"; + for (const prop of CARET_STYLE_PROPS) { + mirror.style[prop] = style[prop]; + } + mirror.textContent = textarea.value.slice(0, position); + const marker = document.createElement("span"); + marker.textContent = textarea.value.slice(position) || "."; + mirror.appendChild(marker); + document.body.appendChild(mirror); + mirror.scrollTop = textarea.scrollTop; + mirror.scrollLeft = textarea.scrollLeft; + const mirrorRect = mirror.getBoundingClientRect(); + const markerRect = marker.getBoundingClientRect(); + document.body.removeChild(mirror); + const lineHeight = + Number.parseFloat(style.lineHeight) || + Number.parseFloat(style.fontSize) * 1.2 || + 16; + return { + top: markerRect.top - mirrorRect.top, + left: markerRect.left - mirrorRect.left, + lineHeight, + }; +};