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
14 changes: 8 additions & 6 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Font } from "@mimo-ai/ui/font"
import { Splash } from "@mimo-ai/ui/logo"
import { ThemeProvider } from "@mimo-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
Expand Down Expand Up @@ -42,6 +42,7 @@ import { ServerConnection, ServerProvider, serverName, useServer } from "@/conte
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import { SessionGrid } from "@/components/session"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
Expand All @@ -55,11 +56,12 @@ if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname))
void loadSession()
}

const SessionRoute = () => (
<SessionProviders>
<Session />
</SessionProviders>
)
const SessionRoute = () => {
const params = useParams<{ dir?: string; id?: string }>()
// Layout store keys are base64 dir strings (see layout.sidebar.workspaces usage).
// Pass params.dir verbatim so per-directory layout state lines up.
return <SessionGrid dir={params.dir ?? ""} primaryId={params.id} />
}

const SessionIndexRoute = () => <Navigate href="session" />

Expand Down
113 changes: 113 additions & 0 deletions packages/app/src/components/session/cell-session-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { DropdownMenu } from "@mimo-ai/ui/dropdown-menu"
import { showToast } from "@mimo-ai/ui/toast"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { sortedRootSessions } from "@/pages/layout/helpers"
import { sessionTitle } from "@/utils/session-title"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"

/**
* Empty grid cell — click to open a session in the slot. Lists the
* directory's existing sessions (excluding the ones already shown) and adds
* the chosen one via `layout.grid.addCell`. The "New session" entry uses the
* standard SDK client to create a fresh session in the same directory.
*
* Scope: same-directory only. Cross-project session listing and per-cell
* workspace selection require a future `experimental.session.list` /
* `experimental.workspace.list` server endpoint plus a workspace-client pool;
* those are intentionally omitted here.
*/
export function CellSessionPicker(props: { dir: string; primaryId?: string }) {
const layout = useLayout()
const globalSync = useGlobalSync()
const sdk = useSDK()
const sync = useSync()
const language = useLanguage()
const [store] = globalSync.child(props.dir, { bootstrap: false })
const [open, setOpen] = createSignal(false)
const [busy, setBusy] = createSignal(false)

const taken = createMemo(() => {
const set = new Set(layout.grid.cellsByID(props.dir)())
if (props.primaryId) set.add(props.primaryId)
return set
})

const sessions = createMemo(() => {
return sortedRootSessions(store, Date.now()).filter((s) => !taken().has(s.id))
})

const createSession = async () => {
setBusy(true)
try {
const result = await sdk.client.session.create().catch(() => undefined)
const sessionID = result?.data?.id
const directory = result?.data?.directory
if (!sessionID) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: "Failed to create session",
})
return
}
layout.grid.addCell(props.dir, sessionID, {
directory,
label: "New Session",
})
// Make sure the new session's events flow into the sync store.
void sync.session.sync(sessionID).catch(() => undefined)
setOpen(false)
} finally {
setBusy(false)
}
}

return (
<div class="flex size-full flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border-weak-base bg-background-stronger p-3">
<DropdownMenu gutter={4} placement="bottom" open={open()} onOpenChange={setOpen}>
<DropdownMenu.Trigger class="flex flex-col items-center justify-center gap-1 rounded-md px-4 py-3 text-text-weak transition-colors hover:bg-background-base hover:text-text-base">
<span class="text-16-regular">+</span>
<span class="text-12-regular">Open session</span>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="max-h-80 overflow-y-auto">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel>New</DropdownMenu.GroupLabel>
<DropdownMenu.Item disabled={busy()} onSelect={() => void createSession()}>
<DropdownMenu.ItemLabel>{busy() ? "Creating…" : "New session"}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Group>
<Show when={sessions().length > 0}>
<DropdownMenu.Group>
<DropdownMenu.GroupLabel>Existing sessions</DropdownMenu.GroupLabel>
<For each={sessions()}>
{(s) => (
<DropdownMenu.Item
onSelect={() => {
layout.grid.addCell(props.dir, s.id, { directory: props.dir })
setOpen(false)
}}
>
<DropdownMenu.ItemLabel>{sessionTitle(s.title)}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
)}
</For>
</DropdownMenu.Group>
</Show>
<Show when={sessions().length === 0}>
<DropdownMenu.Group>
<DropdownMenu.Item disabled>
<DropdownMenu.ItemLabel>No other sessions</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Group>
</Show>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)
}
67 changes: 67 additions & 0 deletions packages/app/src/components/session/grid-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Button } from "@mimo-ai/ui/button"
import { DropdownMenu } from "@mimo-ai/ui/dropdown-menu"
import { Icon } from "@mimo-ai/ui/icon"
import { IconButton } from "@mimo-ai/ui/icon-button"
import { Tooltip } from "@mimo-ai/ui/tooltip"
import { Show, createSignal } from "solid-js"
import { useLayout } from "@/context/layout"

const GRID_MODES = [1, 2, 3, 4, 6, 8] as const

export function GridToggle(props: { dir: string }) {
const layout = useLayout()
const [open, setOpen] = createSignal(false)
const currentMode = () => layout.grid.mode(props.dir)()

return (
<DropdownMenu gutter={4} placement="bottom-end" open={open()} onOpenChange={setOpen}>
<Tooltip placement="bottom" value="Grid layout">
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
aria-label="Grid layout"
/>
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.GroupLabel>Grid</DropdownMenu.GroupLabel>
{GRID_MODES.map((mode) => (
<DropdownMenu.Item
onSelect={() => {
layout.grid.setMode(props.dir, mode)
setOpen(false)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Show when={currentMode() === mode}>
<Icon name="check-small" size="small" class="text-icon-weak" />
</Show>
</div>
<DropdownMenu.ItemLabel>{mode}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
const active = layout.grid.active(props.dir)()
const cells = layout.grid.cells(props.dir)()
const isExtra = active && cells.some((c) => c.sessionID === active)
if (isExtra) {
layout.grid.removeCell(props.dir, active)
} else {
layout.grid.setMode(props.dir, 1)
}
setOpen(false)
}}
>
<DropdownMenu.ItemLabel>Close session</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)
}
3 changes: 3 additions & 0 deletions packages/app/src/components/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
export { SessionGrid } from "./session-grid"
export { GridToggle } from "./grid-toggle"
export { CellSessionPicker } from "./cell-session-picker"
157 changes: 157 additions & 0 deletions packages/app/src/components/session/session-grid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { createMemo, ErrorBoundary, For, Show } from "solid-js"
import { useLayout, type GridCell } from "@/context/layout"
import Page from "@/pages/session"
import { SessionProviders } from "@/components/session/session-providers"
import { CellSessionPicker } from "@/components/session/cell-session-picker"

const GRID_COLS: Record<number, string> = {
1: "1fr",
2: "1fr 1fr",
3: "1fr 1fr 1fr",
4: "1fr 1fr",
6: "1fr 1fr 1fr",
8: "1fr 1fr 1fr 1fr",
}

const GRID_ROWS: Record<number, string> = {
1: "1fr",
2: "1fr",
3: "1fr",
4: "1fr 1fr",
6: "1fr 1fr",
8: "1fr 1fr",
}

/**
* Each cell gets its own session context stack (Terminal/File/Prompt/Comments)
* so parallel sessions have independent state and useFile() etc. resolve.
* Only the ACTIVE cell renders "full" (header chrome); the rest render
* "cell" so the top controls aren't duplicated per cell.
*/
function Cell(props: {
dir: string
cell: GridCell
active: boolean
onActivate?: () => void
onRemove?: () => void
}) {
return (
<div
class="relative overflow-hidden rounded-md border bg-background-stronger"
classList={{
"border-border-base ring-1 ring-border-base": props.active,
"border-border-weak-base": !props.active,
}}
onPointerDown={() => props.onActivate?.()}
>
<SessionProviders>
<ErrorBoundary
fallback={(err: unknown) => (
<div class="flex size-full flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border-weak-base bg-background-stronger p-3">
<div class="text-12-regular text-text-weak">Session unreachable</div>
<Show when={typeof (err as Error)?.message === "string"}>
<div class="max-w-[90%] truncate text-12-regular text-text-weaker">
{(err as Error).message}
</div>
</Show>
<button
type="button"
class="rounded-md px-1 py-0.5 text-10-regular text-text-weak transition-colors hover:bg-background-base hover:text-text-base"
onClick={() => props.onRemove?.()}
>
Remove
</button>
</div>
)}
>
<Page sessionID={props.cell.sessionID} mode={props.active ? "full" : "cell"} />
</ErrorBoundary>
</SessionProviders>
</div>
)
}

/**
* SessionGrid composes a CSS grid of session cells for the active directory.
* Cell 0 is always the primary (current route) session. Page handles the
* new-session state when primaryId is undefined, and its header carries the
* Grid toggle. Extra cells fill the remaining grid slots and are added via
* the empty-cell picker.
*
* Layout preferences (mode + cell assignments + active cell) are persisted
* per-directory via the layout store.
*/
export function SessionGrid(props: { dir: string; primaryId?: string }) {
const layout = useLayout()
const mode = createMemo(() => layout.grid.mode(props.dir)())
const cells = createMemo(() => layout.grid.cells(props.dir)())

// Cell 0 is always the primary (current route) session. Extra cells
// exclude the primary and stay under the slot budget.
const extraCells = createMemo(() => {
const seen = new Set<string>()
return cells()
.filter((c) => c.sessionID !== props.primaryId)
.filter((c) => {
if (seen.has(c.sessionID)) return false
seen.add(c.sessionID)
return true
})
.slice(0, Math.max(0, mode() - 1))
})
const emptyCount = createMemo(() => Math.max(0, mode() - 1 - extraCells().length))

// Synthesize an ephemeral primary cell so the chrome still works when the
// route doesn't carry a session id (new-session state).
const primaryCell = createMemo<GridCell>(() => ({
id: props.primaryId ?? "primary",
sessionID: props.primaryId ?? "",
directory: props.dir,
mode: "full",
label: "",
}))

const activeId = createMemo(() => layout.grid.active(props.dir)() ?? primaryCell().id)

return (
<Show
when={mode() > 1}
fallback={
// mode 1: plain single session (full page + header + Grid toggle).
<SessionProviders>
<Page sessionID={props.primaryId} mode="full" />
</SessionProviders>
}
>
<div
class="size-full grid gap-1 p-1 bg-background-base"
style={{
"grid-template-columns": GRID_COLS[mode()],
"grid-template-rows": GRID_ROWS[mode()],
}}
>
<Cell
dir={props.dir}
cell={primaryCell()}
active={activeId() === primaryCell().id}
onActivate={() => layout.grid.setActive(props.dir, primaryCell().id)}
onRemove={() => layout.grid.setMode(props.dir, 1)}
/>
<For each={extraCells()}>
{(cell) => (
<Cell
dir={props.dir}
cell={cell}
active={activeId() === cell.id}
onActivate={() => layout.grid.setActive(props.dir, cell.id)}
onRemove={() => layout.grid.removeCell(props.dir, cell.sessionID)}
/>
)}
</For>
<For each={Array.from({ length: emptyCount() })}>
{() => <CellSessionPicker dir={props.dir} primaryId={props.primaryId} />}
</For>
</div>
</Show>
)
}
Loading