From 5159bdec3f85c304e551859e566cdd2211e90e4d Mon Sep 17 00:00:00 2001 From: vlgalib <72434411+vlgalib@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:07:02 +0100 Subject: [PATCH] feat(web): add grid mode for multi-session workspace Adds a grid-based workspace layout to the web app, allowing users to arrange multiple sessions side-by-side (1/2/3/4/6/8 layouts). Includes: - SessionGrid component composing multiple SessionView instances - GridToggle sidebar control for switching layout modes - CellSessionPicker for assigning sessions to grid cells - gridMode layout context for per-directory persistence Co-authored-by: hiai-bobcode contributors --- packages/app/src/app.tsx | 14 +- .../session/cell-session-picker.tsx | 113 +++++++++++++ .../src/components/session/grid-toggle.tsx | 67 ++++++++ packages/app/src/components/session/index.ts | 3 + .../src/components/session/session-grid.tsx | 157 ++++++++++++++++++ .../src/components/session/session-header.tsx | 12 +- .../components/session/session-providers.tsx | 24 +++ packages/app/src/context/layout.tsx | 61 +++++++ packages/app/src/pages/session.tsx | 78 ++++----- 9 files changed, 482 insertions(+), 47 deletions(-) create mode 100644 packages/app/src/components/session/cell-session-picker.tsx create mode 100644 packages/app/src/components/session/grid-toggle.tsx create mode 100644 packages/app/src/components/session/session-grid.tsx create mode 100644 packages/app/src/components/session/session-providers.tsx diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index f53cd7c61..d1cabc70a 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -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 { @@ -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" @@ -55,11 +56,12 @@ if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) void loadSession() } -const SessionRoute = () => ( - - - -) +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 +} const SessionIndexRoute = () => diff --git a/packages/app/src/components/session/cell-session-picker.tsx b/packages/app/src/components/session/cell-session-picker.tsx new file mode 100644 index 000000000..c7b148ec6 --- /dev/null +++ b/packages/app/src/components/session/cell-session-picker.tsx @@ -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 ( +
+ + + + + Open session + + + + + New + void createSession()}> + {busy() ? "Creating…" : "New session"} + + + 0}> + + Existing sessions + + {(s) => ( + { + layout.grid.addCell(props.dir, s.id, { directory: props.dir }) + setOpen(false) + }} + > + {sessionTitle(s.title)} + + )} + + + + + + + No other sessions + + + + + + +
+ ) +} diff --git a/packages/app/src/components/session/grid-toggle.tsx b/packages/app/src/components/session/grid-toggle.tsx new file mode 100644 index 000000000..04b6b4ac9 --- /dev/null +++ b/packages/app/src/components/session/grid-toggle.tsx @@ -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 ( + + + + + + + + Grid + {GRID_MODES.map((mode) => ( + { + layout.grid.setMode(props.dir, mode) + setOpen(false) + }} + > +
+ + + +
+ {mode} +
+ ))} +
+ + { + 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) + }} + > + Close session + +
+
+
+ ) +} diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index 20124b6fd..f6a25aea2 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -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" diff --git a/packages/app/src/components/session/session-grid.tsx b/packages/app/src/components/session/session-grid.tsx new file mode 100644 index 000000000..650005338 --- /dev/null +++ b/packages/app/src/components/session/session-grid.tsx @@ -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 = { + 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 = { + 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 ( +
props.onActivate?.()} + > + + ( +
+
Session unreachable
+ +
+ {(err as Error).message} +
+
+ +
+ )} + > + +
+
+
+ ) +} + +/** + * 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() + 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(() => ({ + id: props.primaryId ?? "primary", + sessionID: props.primaryId ?? "", + directory: props.dir, + mode: "full", + label: "", + })) + + const activeId = createMemo(() => layout.grid.active(props.dir)() ?? primaryCell().id) + + return ( + 1} + fallback={ + // mode 1: plain single session (full page + header + Grid toggle). + + + + } + > +
+ layout.grid.setActive(props.dir, primaryCell().id)} + onRemove={() => layout.grid.setMode(props.dir, 1)} + /> + + {(cell) => ( + layout.grid.setActive(props.dir, cell.id)} + onRemove={() => layout.grid.removeCell(props.dir, cell.sessionID)} + /> + )} + + + {() => } + +
+
+ ) +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index a7f8834d6..fe94b6665 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -20,6 +20,7 @@ import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { focusTerminalById } from "@/pages/session/helpers" +import { GridToggle } from "./grid-toggle" import { useSessionLayout } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" @@ -129,7 +130,8 @@ const showRequestError = (language: ReturnType, err: unknown }) } -export function SessionHeader() { +export function SessionHeader(props: { sessionID?: string } = {}) { + const effectiveSessionID = createMemo(() => props.sessionID ?? params.id) const layout = useLayout() const command = useCommand() const server = useServer() @@ -228,9 +230,10 @@ export function SessionHeader() { ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), ) const opening = createMemo(() => openRequest.app !== undefined) - const tint = createMemo(() => - messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent), - ) + const tint = createMemo(() => { + const id = effectiveSessionID() + return messageAgentColor(id ? sync.data.message[id] : undefined, sync.data.agent) + }) const selectApp = (app: OpenApp) => { if (!options().some((item) => item.id === app)) return @@ -431,6 +434,7 @@ export function SessionHeader() { + grid import + * cycle. + */ +export function SessionProviders(props: ParentProps) { + return ( + + + + {props.children} + + + + ) +} \ No newline at end of file diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 57cbb716e..05a573809 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -55,6 +55,16 @@ export type LocalProject = Partial & { worktree: string; expanded: bool export type ReviewDiffStyle = "unified" | "split" +export type GridMode = 1 | 2 | 3 | 4 | 6 | 8 + +export type GridCell = { + id: string + sessionID: string + directory?: string + mode: "full" | "cell" + label: string +} + export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) { touch(key) seed(key) @@ -255,6 +265,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( mobileSidebar: { opened: false, }, + gridMode: {} as Record, + gridCells: {} as Record, + gridActive: {} as Record, sessionTabs: {} as Record, sessionView: {} as Record, handoff: { @@ -702,6 +715,54 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("mobileSidebar", "opened", (x) => !x) }, }, + grid: { + mode(directory: string) { + return createMemo(() => store.gridMode[directory] ?? 1) + }, + setMode(directory: string, mode: GridMode) { + setStore("gridMode", directory, mode) + if (mode === 1) { + const current = store.gridCells[directory] ?? [] + if (current.length > 0) setStore("gridCells", directory, []) + if (store.gridActive[directory]) setStore("gridActive", directory, undefined as unknown as string) + } + }, + active(directory: string) { + return createMemo(() => store.gridActive[directory]) + }, + setActive(directory: string, id: string) { + setStore("gridActive", directory, id) + }, + cells(directory: string) { + return createMemo(() => store.gridCells[directory] ?? []) + }, + cellsByID(directory: string) { + return createMemo(() => (store.gridCells[directory] ?? []).map((c) => c.sessionID)) + }, + setCells(directory: string, cells: GridCell[]) { + setStore("gridCells", directory, cells) + }, + addCell(directory: string, sessionID: string, opts?: { directory?: string; label?: string }) { + const current = store.gridCells[directory] ?? [] + if (current.some((c) => c.sessionID === sessionID)) return + const cell: GridCell = { + id: `${sessionID}-${Date.now()}`, + sessionID, + directory: opts?.directory ?? directory, + mode: "cell", + label: opts?.label ?? "", + } + setStore("gridCells", directory, [...current, cell]) + }, + removeCell(directory: string, sessionID: string) { + const current = store.gridCells[directory] ?? [] + setStore( + "gridCells", + directory, + current.filter((c) => c.sessionID !== sessionID), + ) + }, + }, pendingMessage: { set(sessionKey: string, messageID: string) { const at = Date.now() diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4c7b204b7..cf68937ac 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -318,7 +318,9 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { } } -export default function Page() { +export default function Page(props: { sessionID?: string; mode?: "full" | "cell" } = {}) { + const effectiveSessionID = createMemo(() => props.sessionID ?? params.id) + const isCellMode = () => props.mode === "cell" const globalSync = useGlobalSync() const layout = useLayout() const local = useLocal() @@ -338,7 +340,7 @@ export default function Page() { createEffect(() => { if (!prompt.ready()) return untrack(() => { - if (params.id) return + if (effectiveSessionID()) return const text = searchParams.prompt if (!text) return prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) @@ -364,7 +366,7 @@ export default function Page() { createEffect( on( - () => params.id, + () => effectiveSessionID(), (id, prev) => { if (!id) return if (prev) return @@ -431,9 +433,9 @@ export default function Page() { if (!view().reviewPanel.opened()) view().reviewPanel.open() } - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const info = createMemo(() => { const id = effectiveSessionID(); return id ? sync.session.get(id) : undefined }) const isChildSession = createMemo(() => !!info()?.parentID) - const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : [])) + const diffs = createMemo(() => { const id = effectiveSessionID(); return id ? list(sync.data.session_diff[id]) : [] }) const canReview = createMemo(() => !!sync.project) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ @@ -446,19 +448,19 @@ export default function Page() { const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab const revertMessageID = createMemo(() => info()?.revert?.messageID) - const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const messages = createMemo(() => { const id = effectiveSessionID(); return id ? (sync.data.message[id] ?? []) : [] }) const messagesReady = createMemo(() => { - const id = params.id + const id = effectiveSessionID() if (!id) return true return sync.data.message[id] !== undefined }) const historyMore = createMemo(() => { - const id = params.id + const id = effectiveSessionID() if (!id) return false return sync.session.history.more(id) }) const historyLoading = createMemo(() => { - const id = params.id + const id = effectiveSessionID() if (!id) return false return sync.session.history.loading(id) }) @@ -501,7 +503,7 @@ export default function Page() { createEffect( on( - () => ({ dir: params.dir, id: params.id }), + () => ({ dir: params.dir, id: effectiveSessionID() }), (next, prev) => { if (!prev) return if (next.dir === prev.dir && next.id === prev.id) return @@ -756,7 +758,7 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs const [sessionSync] = createResource( - () => [sdk.directory, params.id] as const, + () => [sdk.directory, effectiveSessionID()] as const, ([directory, id]) => { if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) @@ -777,7 +779,7 @@ export default function Page() { refreshFrame = undefined refreshTimer = window.setTimeout(() => { refreshTimer = undefined - if (params.id !== id) return + if (effectiveSessionID() !== id) return untrack(() => { if (stale) void sync.session.sync(id, { force: true }) }) @@ -791,7 +793,7 @@ export default function Page() { createEffect( on( () => { - const id = params.id + const id = effectiveSessionID() return [ sdk.directory, id, @@ -812,7 +814,7 @@ export default function Page() { todoFrame = undefined todoTimer = window.setTimeout(() => { todoTimer = undefined - if (sdk.directory !== dir || params.id !== id) return + if (sdk.directory !== dir || effectiveSessionID() !== id) return untrack(() => { void sync.session.todo(id, cached ? { force: true } : undefined) }) @@ -990,7 +992,7 @@ export default function Page() { createEffect( on( - () => sync.data.session_status[params.id ?? ""]?.type, + () => sync.data.session_status[effectiveSessionID() ?? ""]?.type, (next, prev) => { if (next !== "idle" || prev === undefined || prev === "idle") return refreshVcs() @@ -1257,7 +1259,7 @@ export default function Page() { }) createEffect(() => { - const id = params.id + const id = effectiveSessionID() if (!id) return if (!wantsReview()) return @@ -1277,7 +1279,7 @@ export default function Page() { diffTimer = undefined if (!wants) return - const id = params.id + const id = effectiveSessionID() if (!id) return if (!untrack(() => sync.data.session_diff[id] !== undefined)) return @@ -1404,7 +1406,7 @@ export default function Page() { ) const historyWindow = createSessionHistoryWindow({ - sessionID: () => params.id, + sessionID: () => effectiveSessionID(), messagesReady, loaded: () => messages().length, visibleUserMessages, @@ -1421,7 +1423,7 @@ export default function Page() { fillFrame = requestAnimationFrame(() => { fillFrame = undefined - if (!params.id || !messagesReady()) return + if (!effectiveSessionID() || !messagesReady()) return if (autoScroll.userScrolled() || historyLoading()) return const el = scroller @@ -1437,7 +1439,7 @@ export default function Page() { on( () => [ - params.id, + effectiveSessionID(), messagesReady(), historyWindow.turnStart(), historyMore(), @@ -1504,13 +1506,13 @@ export default function Page() { } const queuedFollowups = createMemo(() => { - const id = params.id + const id = effectiveSessionID() if (!id) return emptyFollowups return followup.items[id] ?? emptyFollowups }) const editingFollowup = createMemo(() => { - const id = params.id + const id = effectiveSessionID() if (!id) return return followup.edit[id] }) @@ -1545,14 +1547,14 @@ export default function Page() { followupMutation.isPending && followupMutation.variables?.sessionID === sessionID const sendingFollowup = createMemo(() => { - const id = params.id + const id = effectiveSessionID() if (!id) return if (!followupBusy(id)) return return followupMutation.variables?.id }) const queueEnabled = createMemo(() => { - const id = params.id + const id = effectiveSessionID() if (!id) return false return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession() }) @@ -1595,7 +1597,7 @@ export default function Page() { } const editFollowup = (id: string) => { - const sessionID = params.id + const sessionID = effectiveSessionID() if (!sessionID) return if (followupBusy(sessionID)) return @@ -1612,7 +1614,7 @@ export default function Page() { } const clearFollowupEdit = () => { - const id = params.id + const id = effectiveSessionID() if (!id) return setFollowup("edit", id, undefined) } @@ -1646,7 +1648,7 @@ export default function Page() { const restoreMutation = useMutation(() => ({ mutationFn: async (id: string) => { - const sessionID = params.id + const sessionID = effectiveSessionID() if (!sessionID) return const next = userMessages().find((item) => item.id > id) @@ -1694,7 +1696,7 @@ export default function Page() { } const restore = (id: string) => { - if (!params.id || reverting()) return + if (!effectiveSessionID() || reverting()) return return restoreMutation.mutateAsync(id) } @@ -1709,7 +1711,7 @@ export default function Page() { const actions = { revert } createEffect(() => { - const sessionID = params.id + const sessionID = effectiveSessionID() if (!sessionID) return const item = queuedFollowups()[0] @@ -1748,7 +1750,7 @@ export default function Page() { const { clearMessageHash, scrollToMessage } = useSessionHashScroll({ sessionKey, - sessionID: () => params.id, + sessionID: () => effectiveSessionID(), messagesReady, visibleUserMessages, historyMore, @@ -1769,7 +1771,7 @@ export default function Page() { createEffect( on( - () => params.id, + () => effectiveSessionID(), (id) => { if (!id) requestAnimationFrame(() => inputRef?.focus()) }, @@ -1795,9 +1797,11 @@ export default function Page() { return (
{sessionSync() ?? ""} - + + +
- +
- + { - const id = params.id + const id = effectiveSessionID() if (!id) return setFollowup("paused", id, true) }, onSend: (id) => { - void sendFollowup(params.id!, id, { manual: true }) + void sendFollowup(effectiveSessionID()!, id, { manual: true }) }, onEdit: editFollowup, onEditLoaded: clearFollowupEdit,