Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions app-prefixable/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Router, Route, Navigate, useParams } from "@solidjs/router"
import { createSignal, onMount, onCleanup, For } from "solid-js"
import { createSignal, onMount, onCleanup, For, type Accessor } from "solid-js"
import { BasePathProvider, useBasePath } from "./context/base-path"
import { ServerProvider, useServer } from "./context/server"
import { BrandingProvider } from "./context/branding"
Expand Down Expand Up @@ -33,15 +33,15 @@ function getLastSessionHref(encodedDir: string): string {

function DirectoryIndex() {
const params = useParams<{ dir: string }>()
return <Navigate href={getLastSessionHref(params.dir)} replace />
return <Navigate href={getLastSessionHref(params.dir)} />
}
Comment on lines 34 to 37
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing replace from this redirect changes browser history behavior: navigating to /:dir will now add an extra history entry before redirecting to the session route, which can create a back-button loop (back -> /:dir -> redirect forward again). If the router no longer supports replace on <Navigate>, consider implementing this redirect via useNavigate() with { replace: true } in an effect/onMount to preserve the previous semantics.

Copilot uses AI. Check for mistakes.

function SessionIndex() {
const params = useParams<{ dir: string }>()
const href = getLastSessionHref(params.dir)
if (href === "session") return <Session />
const id = href.replace(/^session\//, "")
return <Navigate href={id} replace />
return <Navigate href={id} />
}
Comment on lines 40 to 45
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: dropping replace here will push an extra history entry when redirecting from /session to the last session id, which can make back navigation bounce between the index route and the redirected route. If replace isn't available on <Navigate>, switch to an imperative navigate(href, { replace: true }) redirect to avoid polluting history.

Copilot uses AI. Check for mistakes.

function AppRoutes() {
Expand Down Expand Up @@ -71,16 +71,21 @@ function AppRoutes() {
* Reads the active directory from window.location (outside Router context).
* Re-evaluates on popstate and on history.pushState/history.replaceState navigation.
*/
function useActiveDirectory() {
function useRouteState() {
const [dir, setDir] = createSignal<string | undefined>(
typeof window === "undefined" ? undefined : deriveDirectoryFromPathname(),
)
const [pathname, setPathname] = createSignal(
typeof window === "undefined" ? "" : window.location.pathname,
)

onMount(() => {
// Ensure correct value once mounted (covers SSR hydration)
setDir(deriveDirectoryFromPathname())
function update() {
setDir(deriveDirectoryFromPathname())
setPathname(window.location.pathname)
}

function update() { setDir(deriveDirectoryFromPathname()) }
update()

// Patch pushState/replaceState to detect SolidJS Router navigations
// instead of polling with setInterval
Expand All @@ -97,7 +102,10 @@ function useActiveDirectory() {
})
})

return dir
return {
activeDirectory: dir,
pathname,
}
}

function useProjectsList() {
Expand Down Expand Up @@ -129,7 +137,11 @@ function useProjectsList() {
return projects
}

function AppWithServer(props: { projects: () => Project[]; activeDirectory: () => string | undefined }) {
function AppWithServer(props: {
projects: () => Project[]
activeDirectory: Accessor<string | undefined>
pathname: Accessor<string>
}) {
const { serverUrl, activeServerKey } = useServer()

// Key by server config to force full remount when switching or editing servers
Expand All @@ -141,7 +153,11 @@ function AppWithServer(props: { projects: () => Project[]; activeDirectory: () =
<BrandingProvider>
<RecentProjectsProvider>
<SavedPromptsProvider directory={props.activeDirectory}>
<GlobalEventsProvider projects={props.projects} activeDirectory={props.activeDirectory}>
<GlobalEventsProvider
projects={props.projects}
activeDirectory={props.activeDirectory}
pathname={props.pathname}
>
<CommandProvider>
<AppRoutes />
</CommandProvider>
Expand All @@ -158,11 +174,15 @@ function AppWithServer(props: { projects: () => Project[]; activeDirectory: () =

export function App() {
const projects = useProjectsList()
const activeDirectory = useActiveDirectory()
const route = useRouteState()

return (
<ServerProvider>
<AppWithServer projects={projects} activeDirectory={activeDirectory} />
<AppWithServer
projects={projects}
activeDirectory={route.activeDirectory}
pathname={route.pathname}
/>
</ServerProvider>
)
}
12 changes: 10 additions & 2 deletions app-prefixable/src/context/global-events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const GlobalEventsContext = createContext<GlobalEventsContextValue>()
export function GlobalEventsProvider(props: ParentProps & {
projects: () => { worktree: string }[]
activeDirectory: () => string | undefined
pathname: () => string
}) {
const { authHeaders, serverUrl, activeServer } = useServer()

Expand Down Expand Up @@ -458,11 +459,18 @@ export function GlobalEventsProvider(props: ParentProps & {
// Active project is excluded — it has its own EventProvider.
// Remote servers are skipped — local projects don't exist on remote servers.
createEffect(on(
() => ({ dirs: props.projects().map((p) => p.worktree), active: props.activeDirectory(), isRemote: !activeServer().isDefault }),
() => ({
dirs: props.projects().map((p) => p.worktree),
active: props.activeDirectory(),
isRemote: !activeServer().isDefault,
isSettings: props.pathname().endsWith("/settings"),
}),
(current) => {
// When a remote server is active, disconnect all global event connections
// since local projects don't exist on the remote server.
if (current.isRemote) {
// Also suspend these background connections on settings routes to avoid
// opening one SSE stream per saved project while the user edits settings.
if (current.isRemote || current.isSettings) {
for (const dir of [...connections.keys()]) {
disconnectDirectory(dir)
}
Expand Down
2 changes: 1 addition & 1 deletion app-prefixable/src/context/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function loadServers(): ServerConfig[] {
const parsed: ServerConfig[] = raw
.filter(isValidServerEntry)
.map((s) => {
const obj = s as Record<string, unknown>
const obj = s as unknown as Record<string, unknown>
// Prefer credentials from sessionStorage; fall back to authMethod stub
if (creds[obj.id as string]) {
return { ...s, auth: normalizeAuth({ auth: creds[obj.id as string] } as unknown as Record<string, unknown>) }
Expand Down
10 changes: 7 additions & 3 deletions app-prefixable/src/context/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export function ThemeProvider(props: ParentProps) {
const query = typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia("(prefers-color-scheme: dark)")
: undefined
const legacyQuery = query as (MediaQueryList & {
addListener?: (listener: (event: MediaQueryListEvent) => void) => void
removeListener?: (listener: (event: MediaQueryListEvent) => void) => void
}) | undefined

const [systemDark, setSystemDark] = createSignal(query?.matches ?? false)

Expand All @@ -48,9 +52,9 @@ export function ThemeProvider(props: ParentProps) {
if ("addEventListener" in query) {
query.addEventListener("change", handler)
onCleanup(() => query.removeEventListener("change", handler))
} else if ("addListener" in query) {
query.addListener(handler)
onCleanup(() => query.removeListener(handler))
} else if (legacyQuery?.addListener && legacyQuery.removeListener) {
legacyQuery.addListener(handler)
onCleanup(() => legacyQuery.removeListener?.(handler))
}
}

Expand Down
12 changes: 7 additions & 5 deletions app-prefixable/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ if (!root) {
throw new Error("Root element not found")
}

const mount = root

// Clear the loading text first
root.innerHTML = ""
mount.innerHTML = ""

async function start() {
const cleanup = await preventLegacyServiceWorkerCaching().catch((e) => {
Expand All @@ -27,17 +29,17 @@ async function start() {
}

console.log("[OpenCode] Rendering...")
render(() => <App />, root)
render(() => <App />, mount)
console.log("[OpenCode] Rendered successfully")
console.log("[OpenCode] Root innerHTML:", root.innerHTML.slice(0, 200))
console.log("[OpenCode] Root innerHTML:", mount.innerHTML.slice(0, 200))
}

start().catch((e) => {
console.error("[OpenCode] Render error:", e)
root.innerHTML = ""
mount.innerHTML = ""
const message = document.createElement("div")
message.style.color = "red"
message.style.padding = "20px"
message.textContent = `Error: ${e instanceof Error && e.message.trim() ? e.message : String(e)}`
root.appendChild(message)
mount.appendChild(message)
})
8 changes: 5 additions & 3 deletions app-prefixable/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -878,14 +878,16 @@ export function Layout(props: ParentProps) {
const [now, setNow] = createSignal(new Date());

onMount(() => {
const timer = { id: 0 as ReturnType<typeof setTimeout> };
let timer: ReturnType<typeof setTimeout> | undefined;
const schedule = () => {
const next = new Date();
next.setHours(24, 0, 0, 0);
timer.id = setTimeout(() => { setNow(new Date()); schedule(); }, next.getTime() - Date.now());
timer = setTimeout(() => { setNow(new Date()); schedule(); }, next.getTime() - Date.now());
};
schedule();
onCleanup(() => clearTimeout(timer.id));
onCleanup(() => {
if (timer) clearTimeout(timer);
});
});

const pinnedSessions = createMemo(() => {
Expand Down
20 changes: 12 additions & 8 deletions shared/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,23 +115,27 @@ export async function handleProxyRequest(path: string, req: Request): Promise<Re
body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined,
})

const corsHeaders = corsOrigin ? { "Access-Control-Allow-Origin": corsOrigin, Vary: "Origin" } : {}
const corsHeaders = new Headers()
if (corsOrigin) {
corsHeaders.set("Access-Control-Allow-Origin", corsOrigin)
corsHeaders.set("Vary", "Origin")
}

if (isSSE) {
if (!response.ok) {
console.error("[Proxy] SSE error:", response.status, response.statusText)
return new Response(response.body, { status: response.status, headers: corsHeaders })
}

const headers = new Headers(corsHeaders)
headers.set("Content-Type", "text/event-stream")
headers.set("Cache-Control", "no-cache")
headers.set("Connection", "keep-alive")
headers.set("X-Accel-Buffering", "no")

return new Response(response.body, {
status: response.status,
headers: {
...corsHeaders,
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
headers,
})
}

Expand Down