diff --git a/opencode.jsonc b/opencode.jsonc new file mode 100644 index 00000000..87c3b993 --- /dev/null +++ b/opencode.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "chrome-devtools": { + "type": "local", + "command": ["npx", "-y", "chrome-devtools-mcp@latest"] + } + } +} diff --git a/package.json b/package.json index 5d234416..291874dc 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,10 @@ "better-sqlite3", "esbuild", "protobufjs" - ] + ], + "patchedDependencies": { + "@solidjs/router@0.15.4": "patches/@solidjs__router@0.15.4.patch" + } }, "packageManager": "pnpm@10.27.0" } diff --git a/packages/app/package.json b/packages/app/package.json index c6b8e420..cd0ca560 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -29,6 +29,7 @@ "@radix-ui/colors": "^3.0.0", "@solid-primitives/event-bus": "^1.1.2", "@solid-primitives/storage": "^4.3.3", + "@solidjs/router": "^0.15.4", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "~2.6.0", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 42234bc2..5ec00c8e 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -1,6 +1,5 @@ import { Match, - Show, Switch, createEffect, createMemo, @@ -10,6 +9,8 @@ import { untrack, } from "solid-js"; +import { useLocation, useNavigate } from "@solidjs/router"; + import type { Agent, Provider } from "@opencode-ai/sdk/v2/client"; import { getVersion } from "@tauri-apps/api/app"; @@ -105,35 +106,73 @@ import { export default function App() { type ProviderAuthMethod = { type: "oauth" | "api"; label: string }; - const initialView: View = (() => { - if (typeof window === "undefined") return "onboarding"; - try { - return window.localStorage.getItem("openwork.onboardingComplete") === "1" - ? "dashboard" - : "onboarding"; - } catch { - return "onboarding"; - } - })(); + const location = useLocation(); + const navigate = useNavigate(); - const [view, _setView] = createSignal(initialView); const [creatingSession, setCreatingSession] = createSignal(false); const [sessionViewLockUntil, setSessionViewLockUntil] = createSignal(0); - const setView = (next: View) => { - // Guard: Don't allow view to change to dashboard while creating session. + const currentView = createMemo(() => { + const path = location.pathname.toLowerCase(); + if (path.startsWith("/onboarding")) return "onboarding"; + if (path.startsWith("/session")) return "session"; + return "dashboard"; + }); + + const [tab, setTabState] = createSignal("home"); + + const goToDashboard = (nextTab: DashboardTab, options?: { replace?: boolean }) => { + setTabState(nextTab); + navigate(`/dashboard/${nextTab}`, options); + }; + + const setTab = (nextTab: DashboardTab) => { + if (currentView() === "dashboard") { + goToDashboard(nextTab); + return; + } + setTabState(nextTab); + }; + + const setView = (next: View, sessionId?: string) => { if (next === "dashboard" && creatingSession()) { return; } if (next === "dashboard" && Date.now() < sessionViewLockUntil()) { return; } - _setView(next); + if (next === "onboarding") { + navigate("/onboarding"); + return; + } + if (next === "session") { + if (sessionId) { + goToSession(sessionId); + return; + } + const fallback = activeSessionId(); + if (fallback) { + goToSession(fallback); + return; + } + navigate("/session"); + return; + } + goToDashboard(tab()); }; + + const goToSession = (sessionId: string, options?: { replace?: boolean }) => { + const trimmed = sessionId.trim(); + if (!trimmed) { + navigate("/session", options); + return; + } + navigate(`/session/${trimmed}`, options); + }; + const [mode, setMode] = createSignal(null); const [onboardingStep, setOnboardingStep] = createSignal("mode"); const [rememberModeChoice, setRememberModeChoice] = createSignal(false); - const [tab, setTab] = createSignal("home"); const [themeMode, setThemeMode] = createSignal(getInitialThemeMode()); const [engineSource, setEngineSource] = createSignal<"path" | "sidecar">( @@ -900,7 +939,7 @@ export default function App() { createEffect(() => { // If we lose the client (disconnect / stop engine), don't strand the user // in a session view that can't operate. - if (view() !== "session") return; + if (currentView() !== "session") return; if (isDemoMode()) return; if (creatingSession()) return; if (client()) return; @@ -1065,7 +1104,7 @@ export default function App() { setDefaultModel(next); setModelPickerOpen(false); - if (typeof window !== "undefined" && view() === "session") { + if (typeof window !== "undefined" && currentView() === "session") { requestAnimationFrame(() => { window.dispatchEvent(new CustomEvent("openwork:focusPrompt")); }); @@ -1344,7 +1383,12 @@ export default function App() { console.log("[DEBUG] current baseUrl:", baseUrl()); console.log("[DEBUG] engine info:", engine()); if (isDemoMode()) { - setView("session"); + const demoId = activeSessionId(); + if (demoId) { + goToSession(demoId); + } else { + setView("session"); + } return; } @@ -1452,7 +1496,7 @@ export default function App() { // Now switch view AFTER session is selected mark("view set to session"); // setSessionViewLockUntil(Date.now() + 1200); - setView("session"); + goToSession(session.id); } catch (e) { mark("error caught", e); const message = e instanceof Error ? e.message : t("app.unknown_error", currentLocale()); @@ -2034,8 +2078,8 @@ export default function App() { workspaceStore.setEngineInstallLogs(notes || null); }, onOpenSettings: () => { - setView("dashboard"); setTab("settings"); + setView("dashboard"); }, themeMode: themeMode(), setThemeMode, @@ -2044,7 +2088,7 @@ export default function App() { const dashboardProps = () => ({ tab: tab(), setTab, - view: view(), + view: currentView(), setView, mode: mode(), baseUrl: baseUrl(), @@ -2186,87 +2230,174 @@ export default function App() { setLanguage: setLocale, }); + const sessionProps = () => ({ + selectedSessionId: activeSessionId(), + setView, + setTab, + activeWorkspaceDisplay: activeWorkspaceDisplay(), + setWorkspaceSearch: workspaceStore.setWorkspaceSearch, + setWorkspacePickerOpen: workspaceStore.setWorkspacePickerOpen, + headerStatus: headerStatus(), + busyHint: busyHint(), + selectedSessionModelLabel: selectedSessionModelLabel(), + openSessionModelPicker: openSessionModelPicker, + activePlugins: sidebarPluginList(), + activePluginStatus: sidebarPluginStatus(), + createSessionAndOpen: createSessionAndOpen, + sendPromptAsync: sendPrompt, + newTaskDisabled: newTaskDisabled(), + sessions: activeSessions().map((session) => ({ + id: session.id, + title: session.title, + slug: session.slug, + })), + selectSession: isDemoMode() ? selectDemoSession : selectSession, + messages: activeMessages(), + todos: activeTodos(), + busyLabel: busyLabel(), + developerMode: developerMode(), + showThinking: showThinking(), + groupMessageParts, + summarizeStep, + expandedStepIds: expandedStepIds(), + setExpandedStepIds: setExpandedStepIds, + expandedSidebarSections: expandedSidebarSections(), + setExpandedSidebarSections: setExpandedSidebarSections, + artifacts: activeArtifacts(), + workingFiles: activeWorkingFiles(), + authorizedDirs: activeAuthorizedDirs(), + busy: busy(), + prompt: prompt(), + setPrompt: setPrompt, + sendPrompt: sendPrompt, + activePermission: activePermissionMemo(), + permissionReplyBusy: permissionReplyBusy(), + respondPermission: respondPermission, + respondPermissionAndRemember: respondPermissionAndRemember, + safeStringify: safeStringify, + showTryNotionPrompt: tryNotionPromptVisible() && notionIsActive(), + openConnect: openConnectFlow, + startProviderAuth: startProviderAuth, + openProviderAuthModal: openProviderAuthModal, + closeProviderAuthModal: closeProviderAuthModal, + providerAuthModalOpen: providerAuthModalOpen(), + providerAuthBusy: providerAuthBusy(), + providerAuthError: providerAuthError(), + providerAuthMethods: providerAuthMethods(), + providers: providers(), + providerConnectedIds: providerConnectedIds(), + listAgents: listAgents, + setSessionAgent: setSessionAgent, + saveSession: saveSessionExport, + sessionStatusById: activeSessionStatusById(), + onTryNotionPrompt: () => { + setPrompt("setup my crm"); + setTryNotionPromptVisible(false); + setNotionSkillInstalled(true); + try { + window.localStorage.setItem("openwork.notionSkillInstalled", "1"); + } catch { + // ignore + } + }, + sessionStatus: selectedSessionStatus(), + renameSession: renameSessionTitle, + error: error(), + }); + + const dashboardTabs = new Set([ + "home", + "sessions", + "templates", + "skills", + "plugins", + "mcp", + "settings", + ]); + + const resolveDashboardTab = (value?: string | null) => { + const normalized = value?.trim().toLowerCase() ?? ""; + if (dashboardTabs.has(normalized as DashboardTab)) { + return normalized as DashboardTab; + } + return "home"; + }; + + const initialRoute = () => { + if (typeof window === "undefined") return "/onboarding"; + try { + return window.localStorage.getItem("openwork.onboardingComplete") === "1" + ? "/dashboard/home" + : "/onboarding"; + } catch { + return "/onboarding"; + } + }; + + createEffect(() => { + const rawPath = location.pathname.trim(); + const path = rawPath.toLowerCase(); + + if (path === "" || path === "/") { + navigate(initialRoute(), { replace: true }); + return; + } + + if (path.startsWith("/dashboard")) { + const [, , tabSegment] = path.split("/"); + const resolvedTab = resolveDashboardTab(tabSegment); + + if (resolvedTab !== tab()) { + setTabState(resolvedTab); + } + if (!tabSegment || tabSegment !== resolvedTab) { + goToDashboard(resolvedTab, { replace: true }); + } + return; + } + + if (path.startsWith("/session")) { + const [, , sessionSegment] = rawPath.split("/"); + const id = (sessionSegment ?? "").trim(); + + if (!id) { + const fallback = activeSessionId(); + if (fallback) { + goToSession(fallback, { replace: true }); + } else { + goToDashboard("sessions", { replace: true }); + } + return; + } + + if (isDemoMode()) { + if (activeSessionId() !== id) { + selectDemoSession(id); + } + return; + } + + if (selectedSessionId() !== id) { + void selectSession(id); + } + return; + } + + if (path.startsWith("/onboarding")) { + return; + } + + navigate("/dashboard/home", { replace: true }); + }); + return ( <> - + - - ({ - id: session.id, - title: session.title, - slug: session.slug, - }))} - selectSession={isDemoMode() ? selectDemoSession : selectSession} - messages={activeMessages()} - todos={activeTodos()} - busyLabel={busyLabel()} - developerMode={developerMode()} - showThinking={showThinking()} - groupMessageParts={groupMessageParts} - summarizeStep={summarizeStep} - expandedStepIds={expandedStepIds()} - setExpandedStepIds={setExpandedStepIds} - expandedSidebarSections={expandedSidebarSections()} - setExpandedSidebarSections={setExpandedSidebarSections} - artifacts={activeArtifacts()} - workingFiles={activeWorkingFiles()} - authorizedDirs={activeAuthorizedDirs()} - busy={busy()} - prompt={prompt()} - setPrompt={setPrompt} - sendPrompt={sendPrompt} - activePermission={activePermissionMemo()} - permissionReplyBusy={permissionReplyBusy()} - respondPermission={respondPermission} - respondPermissionAndRemember={respondPermissionAndRemember} - safeStringify={safeStringify} - showTryNotionPrompt={tryNotionPromptVisible() && notionIsActive()} - openConnect={openConnectFlow} - startProviderAuth={startProviderAuth} - openProviderAuthModal={openProviderAuthModal} - closeProviderAuthModal={closeProviderAuthModal} - providerAuthModalOpen={providerAuthModalOpen()} - providerAuthBusy={providerAuthBusy()} - providerAuthError={providerAuthError()} - providerAuthMethods={providerAuthMethods()} - providers={providers()} - providerConnectedIds={providerConnectedIds()} - listAgents={listAgents} - setSessionAgent={setSessionAgent} - saveSession={saveSessionExport} - sessionStatusById={activeSessionStatusById()} - onTryNotionPrompt={() => { - setPrompt("setup my crm"); - setTryNotionPromptVisible(false); - setNotionSkillInstalled(true); - try { - window.localStorage.setItem("openwork.notionSkillInstalled", "1"); - } catch { - // ignore - } - }} - sessionStatus={selectedSessionStatus()} - renameSession={renameSessionTitle} - error={error()} - /> + + diff --git a/packages/app/src/app/components/workspace-switch-overlay.tsx b/packages/app/src/app/components/workspace-switch-overlay.tsx index deade90d..a021c8a2 100644 --- a/packages/app/src/app/components/workspace-switch-overlay.tsx +++ b/packages/app/src/app/components/workspace-switch-overlay.tsx @@ -1,19 +1,9 @@ import { Show, createMemo } from "solid-js"; -import { Dynamic } from "solid-js/web"; - -import { Folder, Globe, Zap } from "lucide-solid"; import { t, currentLocale } from "../../i18n"; +import OpenWorkLogo from "./openwork-logo"; import type { WorkspaceInfo } from "../lib/tauri"; -function iconForWorkspace(preset: string, workspaceType: string) { - if (workspaceType === "remote") return Globe; - if (preset === "starter") return Zap; - if (preset === "automation") return Folder; - if (preset === "minimal") return Globe; - return Folder; -} - export default function WorkspaceSwitchOverlay(props: { open: boolean; workspace: WorkspaceInfo | null; @@ -58,10 +48,6 @@ export default function WorkspaceSwitchOverlay(props: { return props.workspace.directory?.trim() ?? ""; }); - const Icon = createMemo(() => - iconForWorkspace(props.workspace?.preset ?? "starter", props.workspace?.workspaceType ?? "local") - ); - return (
@@ -99,8 +85,8 @@ export default function WorkspaceSwitchOverlay(props: { class="absolute -inset-1 rounded-full border border-gray-6/30 motion-safe:animate-spin motion-reduce:opacity-60" style={{ "animation-duration": "9s", "animation-direction": "reverse" }} /> -
- +
+
diff --git a/packages/app/src/app/context/workspace.ts b/packages/app/src/app/context/workspace.ts index 34ae6e5f..a3d34e6c 100644 --- a/packages/app/src/app/context/workspace.ts +++ b/packages/app/src/app/context/workspace.ts @@ -408,8 +408,8 @@ export function createWorkspaceStore(options: { options.refreshSkills({ force: true }).catch(() => undefined); if (!options.selectedSessionId()) { - options.setView("dashboard"); options.setTab("home"); + options.setView("dashboard"); } // If the user successfully connected, treat onboarding as complete so we @@ -466,8 +466,8 @@ export function createWorkspaceStore(options: { setWorkspacePickerOpen(false); setCreateWorkspaceOpen(false); - options.setView("dashboard"); options.setTab("home"); + options.setView("dashboard"); markOnboardingComplete(); } catch (e) { const message = e instanceof Error ? e.message : safeStringify(e); @@ -484,11 +484,6 @@ export function createWorkspaceStore(options: { directory?: string | null; displayName?: string | null; }) { - if (!isTauriRuntime()) { - options.setError(t("app.error.tauri_required", currentLocale())); - return false; - } - const baseUrl = input.baseUrl.trim(); if (!baseUrl) { options.setError(t("app.error.remote_base_url_required", currentLocale())); @@ -518,14 +513,37 @@ export function createWorkspaceStore(options: { options.setBusyLabel("status.creating_workspace"); options.setBusyStartedAt(Date.now()); + const normalizedBaseUrl = baseUrl.replace(/\/+$/, ""); + const displayName = input.displayName?.trim() || null; + const workspaceId = `remote:${normalizedBaseUrl}:${resolvedDirectory}`; + try { - const ws = await workspaceCreateRemote({ - baseUrl, - directory: resolvedDirectory ? resolvedDirectory : null, - displayName: input.displayName?.trim() ? input.displayName.trim() : null, - }); - setWorkspaces(ws.workspaces); - syncActiveWorkspaceId(ws.activeId); + if (isTauriRuntime()) { + const ws = await workspaceCreateRemote({ + baseUrl: normalizedBaseUrl, + directory: resolvedDirectory ? resolvedDirectory : null, + displayName, + }); + setWorkspaces(ws.workspaces); + syncActiveWorkspaceId(ws.activeId); + } else { + const nextWorkspace: WorkspaceInfo = { + id: workspaceId, + name: displayName ?? normalizedBaseUrl, + path: "", + preset: "remote", + workspaceType: "remote", + baseUrl: normalizedBaseUrl, + directory: resolvedDirectory || null, + displayName, + }; + + setWorkspaces((prev) => { + const withoutMatch = prev.filter((workspace) => workspace.id !== workspaceId); + return [...withoutMatch, nextWorkspace]; + }); + syncActiveWorkspaceId(workspaceId); + } setProjectDir(resolvedDirectory); setWorkspaceConfig(null); diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index a20d4c1a..a79c7309 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -34,7 +34,7 @@ export type DashboardViewProps = { tab: DashboardTab; setTab: (tab: DashboardTab) => void; view: "dashboard" | "session" | "onboarding"; - setView: (view: "dashboard" | "session" | "onboarding") => void; + setView: (view: "dashboard" | "session" | "onboarding", sessionId?: string) => void; mode: "host" | "client" | null; baseUrl: string; clientConnected: boolean; @@ -211,9 +211,9 @@ export default function DashboardView(props: DashboardViewProps) { const openSessionFromList = (sessionId: string) => { // Defer view switch to avoid click-through on the same event frame. window.setTimeout(() => { - props.setView("session"); - props.setTab("sessions"); void props.selectSession(sessionId); + props.setTab("sessions"); + props.setView("session", sessionId); }, 0); }; diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 24837385..87c75c6d 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -33,7 +33,7 @@ import FlyoutItem from "../components/flyout-item"; export type SessionViewProps = { selectedSessionId: string | null; - setView: (view: View) => void; + setView: (view: View, sessionId?: string) => void; setTab: (tab: DashboardTab) => void; activeWorkspaceDisplay: WorkspaceDisplay; setWorkspaceSearch: (value: string) => void; @@ -655,8 +655,8 @@ export default function SessionView(props: SessionViewProps) {
No session selected