diff --git a/docs/spec/canvas-pane.md b/docs/spec/canvas-pane.md index 1eb1bb1..9cfaa58 100644 --- a/docs/spec/canvas-pane.md +++ b/docs/spec/canvas-pane.md @@ -55,6 +55,7 @@ A `SystemView` drives its own updates: the beads dashboard polls `/beads-data` e - The pane opens and closes from the header Canvas button and the `C` key. - Open or closed state persists in global config. - Position selector supports left, right, top, and bottom docking, and the selected position persists. +- Size selector supports 1:1 (default) and 2:1 — at 2:1 the open pane takes two-thirds of the layout instead of half — and the selected size persists in global config. - The pane is scoped to the focused worktree. If that worktree has docs, the pane shows its active doc. - Worktrees with multiple docs show tab buttons. The active doc's tab always renders — a lone `AgentDoc` now gets a labeled tab (with a compact last-modified age) instead of a bare iframe, and a lone `SystemView` still shows its `.canvas-system-tab` entry so its beads-count badge stays visible. (See also `docs/spec/canvas-authoring-dx.md`.) - Selecting a tab marks that doc viewed. diff --git a/src/Client/App.fs b/src/Client/App.fs index 82de6cf..0d6a519 100644 --- a/src/Client/App.fs +++ b/src/Client/App.fs @@ -180,6 +180,7 @@ let update msg model = { model.Canvas with CanvasPaneOpen = if isFirstLoad then response.CanvasPaneOpen else model.Canvas.CanvasPaneOpen CanvasPosition = if isFirstLoad then response.CanvasPosition else model.Canvas.CanvasPosition + CanvasSize = if isFirstLoad then response.CanvasSize else model.Canvas.CanvasSize PreviousCanvasHashes = currentCanvasHashes CanvasEvents = canvasEvents CanvasSendState = canvasSendState } } @@ -465,6 +466,8 @@ let update msg model = | SetCanvasPosition position -> CanvasUpdate.setCanvasPosition position model + | SetCanvasSize size -> CanvasUpdate.setCanvasSize size model + | SelectCanvasDoc (scopedKey, filename) -> CanvasUpdate.selectCanvasDoc scopedKey filename model | FocusOverviewCard scopedKey -> @@ -696,6 +699,11 @@ let view model dispatch = | CanvasPosition.Top -> "canvas-top" | CanvasPosition.Bottom -> "canvas-bottom" + let canvasSizeClass = + match model.Canvas.CanvasSize with + | CanvasSize.Ratio1To1 -> "canvas-size-1to1" + | CanvasSize.Ratio2To1 -> "canvas-size-2to1" + let dashboardClass = match model.Canvas.CanvasPaneOpen with | true -> $"dashboard canvas-open {canvasPositionClass}" @@ -703,7 +711,7 @@ let view model dispatch = let layoutClass = match model.Canvas.CanvasPaneOpen with - | true -> $"app-layout canvas-open {canvasPositionClass}" + | true -> $"app-layout canvas-open {canvasPositionClass} {canvasSizeClass}" | false -> "app-layout" let cardProps: CardViewProps = diff --git a/src/Client/AppTypes.fs b/src/Client/AppTypes.fs index b2e3e6e..9039302 100644 --- a/src/Client/AppTypes.fs +++ b/src/Client/AppTypes.fs @@ -70,6 +70,7 @@ type Msg = | UserActivity of now: float | ToggleCanvasPane | SetCanvasPosition of CanvasPosition + | SetCanvasSize of CanvasSize | SelectCanvasDoc of scopedKey: string * filename: string | FocusOverviewCard of scopedKey: string | OpenCanvasDoc of scopedKey: string * filename: string diff --git a/src/Client/CanvasPane.fs b/src/Client/CanvasPane.fs index f818cc0..2e6fca5 100644 --- a/src/Client/CanvasPane.fs +++ b/src/Client/CanvasPane.fs @@ -146,6 +146,7 @@ let private overviewView (repos: RepoModel list) (bridgeLiveness: Map unit + SetSize: CanvasSize -> unit SelectDoc: string -> unit OnOverviewClick: string -> unit OnOverviewDocClick: string -> string -> unit @@ -154,8 +155,9 @@ type CanvasPaneCallbacks = DismissDocError: unit -> unit LaunchSession: unit -> unit } -let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus * CanvasDoc) option) (allRepos: RepoModel list) (sendState: CanvasSendState) (docError: DocJsError option) (bridgeLiveness: Map) (unviewedFilenames: Set) (visitedDocs: string list) (callbacks: CanvasPaneCallbacks) = +let view (isOpen: bool) (position: CanvasPosition) (size: CanvasSize) (focusedDoc: (WorktreeStatus * CanvasDoc) option) (allRepos: RepoModel list) (sendState: CanvasSendState) (docError: DocJsError option) (bridgeLiveness: Map) (unviewedFilenames: Set) (visitedDocs: string list) (callbacks: CanvasPaneCallbacks) = let { SetPosition = setPosition + SetSize = setSize SelectDoc = selectDoc OnOverviewClick = onOverviewClick OnOverviewDocClick = onOverviewDocClick @@ -163,14 +165,17 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus DismissError = dismissError DismissDocError = dismissDocError LaunchSession = launchSession } = callbacks - let positionButton (canvasPosition: CanvasPosition) (label: string) (title: string) = + let toggleButton (baseClass: string) (isActive: bool) (onClick: unit -> unit) (label: string) (title: string) = Html.button [ - prop.className (if canvasPosition = position then "canvas-pos-btn active" else "canvas-pos-btn") - prop.onClick (fun _ -> setPosition canvasPosition) + prop.className (if isActive then $"{baseClass} active" else baseClass) + prop.onClick (fun _ -> onClick ()) prop.title title prop.text label ] + let positionButton (canvasPosition: CanvasPosition) label title = + toggleButton "canvas-pos-btn" (canvasPosition = position) (fun () -> setPosition canvasPosition) label title + let positionButtons = Html.div [ prop.className "canvas-pos-group" @@ -182,6 +187,18 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus ] ] + let sizeButton (canvasSize: CanvasSize) label title = + toggleButton "canvas-size-btn" (canvasSize = size) (fun () -> setSize canvasSize) label title + + let sizeButtons = + Html.div [ + prop.className "canvas-size-group" + prop.children [ + sizeButton CanvasSize.Ratio1To1 "1:1" "Canvas same size as dashboard" + sizeButton CanvasSize.Ratio2To1 "2:1" "Make the canvas twice the size of the dashboard" + ] + ] + let headerBar (tabs: Fable.React.ReactElement list) (activeDoc: CanvasDoc option) (showLaunchBtn: bool) = Html.div [ prop.className "canvas-tab-bar" @@ -209,6 +226,7 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus prop.children [ ArchiveViews.archiveIcon ] ] | _ -> () + sizeButtons positionButtons ] ] diff --git a/src/Client/CanvasState.fs b/src/Client/CanvasState.fs index 529decf..5cfe19c 100644 --- a/src/Client/CanvasState.fs +++ b/src/Client/CanvasState.fs @@ -12,6 +12,7 @@ open Elmish type CanvasState = { CanvasPaneOpen: bool CanvasPosition: CanvasPosition + CanvasSize: CanvasSize ActiveCanvasDoc: Map VisitedCanvasDocs: Map LastViewedHashes: Map> @@ -33,6 +34,7 @@ type CanvasState = let empty : CanvasState = { CanvasPaneOpen = false CanvasPosition = CanvasPosition.Right + CanvasSize = CanvasSize.Ratio1To1 ActiveCanvasDoc = Map.empty VisitedCanvasDocs = Map.empty LastViewedHashes = Map.empty diff --git a/src/Client/CanvasUpdate.fs b/src/Client/CanvasUpdate.fs index 58b98be..fe7a1c4 100644 --- a/src/Client/CanvasUpdate.fs +++ b/src/Client/CanvasUpdate.fs @@ -55,6 +55,10 @@ let setCanvasPosition (position: CanvasPosition) (model: Model) = { model with Canvas = { model.Canvas with CanvasPosition = position } }, Cmd.OfAsync.attempt worktreeApi.Value.saveCanvasPosition position (fun _ -> NoOp) +let setCanvasSize (size: CanvasSize) (model: Model) = + { model with Canvas = { model.Canvas with CanvasSize = size } }, + Cmd.OfAsync.attempt worktreeApi.Value.saveCanvasSize size (fun _ -> NoOp) + let selectCanvasDoc (scopedKey: string) (filename: string) (model: Model) = let wasAlreadyVisited = model.Canvas.VisitedCanvasDocs diff --git a/src/Client/CanvasView.fs b/src/Client/CanvasView.fs index 54b08dd..2394629 100644 --- a/src/Client/CanvasView.fs +++ b/src/Client/CanvasView.fs @@ -66,6 +66,7 @@ let view (model: Model) (dispatch: Dispatch) = let canvasCallbacks: CanvasPane.CanvasPaneCallbacks = { SetPosition = SetCanvasPosition >> dispatch + SetSize = SetCanvasSize >> dispatch SelectDoc = selectCanvasDoc OnOverviewClick = onOverviewClick OnOverviewDocClick = onOverviewDocClick @@ -74,4 +75,4 @@ let view (model: Model) (dispatch: Dispatch) = DismissDocError = (fun () -> dispatch DismissCanvasDocError) LaunchSession = launchCanvasSession } - CanvasPane.view model.Canvas.CanvasPaneOpen model.Canvas.CanvasPosition (focusedWorktreeCanvasDoc model) model.Repos model.Canvas.CanvasSendState model.Canvas.DocError model.Canvas.BridgeLiveness focusedUnviewedFilenames focusedVisitedDocs canvasCallbacks + CanvasPane.view model.Canvas.CanvasPaneOpen model.Canvas.CanvasPosition model.Canvas.CanvasSize (focusedWorktreeCanvasDoc model) model.Repos model.Canvas.CanvasSendState model.Canvas.DocError model.Canvas.BridgeLiveness focusedUnviewedFilenames focusedVisitedDocs canvasCallbacks diff --git a/src/Client/index.html b/src/Client/index.html index 9a2b7d3..1d7b8a0 100644 --- a/src/Client/index.html +++ b/src/Client/index.html @@ -462,6 +462,12 @@ .app-layout.canvas-bottom > .canvas-pane.open { flex: 0 0 50%; } + .app-layout.canvas-size-2to1.canvas-left > .canvas-pane.open, + .app-layout.canvas-size-2to1.canvas-right > .canvas-pane.open, + .app-layout.canvas-size-2to1.canvas-top > .canvas-pane.open, + .app-layout.canvas-size-2to1.canvas-bottom > .canvas-pane.open { + flex: 0 0 66.667%; + } .app-layout.canvas-right > .canvas-pane { border-left: 1px solid #313244; } .app-layout.canvas-left > .canvas-pane { border-right: 1px solid #313244; } .app-layout.canvas-top > .canvas-pane { border-bottom: 1px solid #313244; } @@ -482,7 +488,7 @@ flex: 1; min-width: 0; } - .canvas-pos-group { + .canvas-pos-group, .canvas-size-group { display: flex; gap: 4px; flex-shrink: 0; @@ -513,15 +519,15 @@ opacity: 0.6; } .canvas-launch-btn:hover { color: #a6e3a1; border-color: #a6e3a1; opacity: 1; } - .canvas-pos-btn { + .canvas-pos-btn, .canvas-size-btn { background: #313244; border: 1px solid #45475a; border-radius: 4px; padding: 2px 8px; font-size: 0.85em; cursor: pointer; color: #bac2de; font-family: inherit; transition: background 0.15s, border-color 0.15s, opacity 0.15s; line-height: 1.4; opacity: 0.4; } - .canvas-pos-btn:hover { background: #45475a; border-color: #585b70; opacity: 1; } - .canvas-pos-btn.active { background: #2e3452; border-color: #89b4fa; color: #89b4fa; opacity: 0.4; } - .canvas-pos-btn.active:hover { opacity: 1; } + .canvas-pos-btn:hover, .canvas-size-btn:hover { background: #45475a; border-color: #585b70; opacity: 1; } + .canvas-pos-btn.active, .canvas-size-btn.active { background: #2e3452; border-color: #89b4fa; color: #89b4fa; opacity: 0.4; } + .canvas-pos-btn.active:hover, .canvas-size-btn.active:hover { opacity: 1; } .canvas-error-banner { display: flex; align-items: center; justify-content: space-between; background: #3a1e2a; color: #f38ba8; padding: 4px 12px; diff --git a/src/Server/DemoFixture.fs b/src/Server/DemoFixture.fs index c15770a..887d2b0 100644 --- a/src/Server/DemoFixture.fs +++ b/src/Server/DemoFixture.fs @@ -358,7 +358,8 @@ let private baseDashboard: DashboardResponse = EditorName = "VS Code" CollapsedRepos = Set.empty CanvasPaneOpen = false - CanvasPosition = CanvasPosition.Right } + CanvasPosition = CanvasPosition.Right + CanvasSize = CanvasSize.Ratio1To1 } let private baseFixture: FixtureData = { Worktrees = baseDashboard diff --git a/src/Server/WorktreeApi.fs b/src/Server/WorktreeApi.fs index b47d3a3..12d09fc 100644 --- a/src/Server/WorktreeApi.fs +++ b/src/Server/WorktreeApi.fs @@ -62,6 +62,7 @@ let readOnlyApi saveCollapsedRepos = fun _ -> async { return () } saveCanvasPaneOpen = fun _ -> async { return () } saveCanvasPosition = fun _ -> async { return () } + saveCanvasSize = fun _ -> async { return () } resumeSession = fun _ -> async { return Error $"Session management is not available in {modeName}" } sendCanvasMessage = fun _ -> async { return CanvasMessageResult.Queued } archiveCanvasDoc = fun _ -> async { return Error $"Archive canvas doc is not available in {modeName}" } @@ -394,6 +395,22 @@ let private writeCanvasPosition (position: CanvasPosition) = | CanvasPosition.Bottom -> "bottom" updateGlobalConfig "canvas position" [ "canvasPosition", System.Text.Json.Nodes.JsonValue.Create(value) :> System.Text.Json.Nodes.JsonNode ] +let private readCanvasSize () : CanvasSize = + withConfigDocument CanvasSize.Ratio1To1 (fun root -> + match root.TryGetProperty("canvasSize") with + | true, prop when prop.ValueKind = System.Text.Json.JsonValueKind.String -> + match prop.GetString() with + | "2to1" -> CanvasSize.Ratio2To1 + | _ -> CanvasSize.Ratio1To1 + | _ -> CanvasSize.Ratio1To1) + +let private writeCanvasSize (size: CanvasSize) = + let value = + match size with + | CanvasSize.Ratio1To1 -> "1to1" + | CanvasSize.Ratio2To1 -> "2to1" + updateGlobalConfig "canvas size" [ "canvasSize", System.Text.Json.Nodes.JsonValue.Create(value) :> System.Text.Json.Nodes.JsonNode ] + let private readLastViewedHashes () : Map> = withConfigDocument Map.empty (fun root -> match root.TryGetProperty("lastViewedHashes") with @@ -481,7 +498,8 @@ let getWorktrees EditorName = getEditorConfig () |> snd CollapsedRepos = readCollapsedRepos () CanvasPaneOpen = readCanvasPaneOpen () - CanvasPosition = readCanvasPosition () } + CanvasPosition = readCanvasPosition () + CanvasSize = readCanvasSize () } } let private openEditor (validatePath: string -> Async) (wtPath: WorktreePath) = @@ -615,7 +633,7 @@ let worktreeApi | Some f -> { readOnlyApi "fixture mode" - (fun () -> async { return { f.Worktrees with DeployBranch = None; SystemMetrics = None; EditorName = getEditorConfig () |> snd; CollapsedRepos = readCollapsedRepos (); CanvasPaneOpen = false; CanvasPosition = CanvasPosition.Right } }) + (fun () -> async { return { f.Worktrees with DeployBranch = None; SystemMetrics = None; EditorName = getEditorConfig () |> snd; CollapsedRepos = readCollapsedRepos (); CanvasPaneOpen = false; CanvasPosition = CanvasPosition.Right; CanvasSize = CanvasSize.Ratio1To1 } }) (fun () -> async { return f.SyncStatus }) with getBranches = fun _ -> async { return [ "main"; "develop"; "feature/sample" ] } @@ -787,6 +805,7 @@ let worktreeApi saveCollapsedRepos = fun repos -> async { writeCollapsedRepos repos } saveCanvasPaneOpen = fun isOpen -> async { writeCanvasPaneOpen isOpen } saveCanvasPosition = fun pos -> async { writeCanvasPosition pos } + saveCanvasSize = fun size -> async { writeCanvasSize size } resumeSession = fun wtPath -> withValidatedPath wtPath "resumeSession" (fun () -> async { diff --git a/src/Shared/Types.fs b/src/Shared/Types.fs index 879205d..39841c4 100644 --- a/src/Shared/Types.fs +++ b/src/Shared/Types.fs @@ -97,6 +97,14 @@ type CanvasPosition = | Top | Bottom +/// Relative size of the canvas pane vs the dashboard when the pane is open. +/// Ratio1To1 (default) splits the layout evenly; Ratio2To1 gives the canvas +/// twice the dashboard's share. +[] +type CanvasSize = + | Ratio1To1 + | Ratio2To1 + type CanvasDocKind = | AgentDoc // authored & owned by a session; interactive; file-driven | SystemView // server-generated; data-driven; no owner (e.g. the beads dashboard) @@ -239,7 +247,8 @@ type DashboardResponse = EditorName: string CollapsedRepos: Set CanvasPaneOpen: bool - CanvasPosition: CanvasPosition } + CanvasPosition: CanvasPosition + CanvasSize: CanvasSize } type FixtureData = { Worktrees: DashboardResponse @@ -270,6 +279,7 @@ type IWorktreeApi = saveCollapsedRepos: RepoId list -> Async saveCanvasPaneOpen: bool -> Async saveCanvasPosition: CanvasPosition -> Async + saveCanvasSize: CanvasSize -> Async resumeSession: WorktreePath -> Async> sendCanvasMessage: CanvasMessageRequest -> Async archiveCanvasDoc: ArchiveCanvasDocRequest -> Async> diff --git a/src/Tests/ArchiveTests.fs b/src/Tests/ArchiveTests.fs index 618f832..9797e00 100644 --- a/src/Tests/ArchiveTests.fs +++ b/src/Tests/ArchiveTests.fs @@ -359,7 +359,7 @@ type ArchiveE2ETests() = let makeDashboardJson (worktrees: string list) = let wts = worktrees |> String.concat "," - $"""{{"Repos":[{{"RepoId":{{"RepoId":"TestRepo"}},"RootFolderName":"TestRepo","Worktrees":[{wts}],"IsReady":true,"BaseBranch":"main"}}],"SchedulerEvents":[],"LatestByCategory":{{}},"AppVersion":"test","EditorName":"","CollapsedRepos":[],"CanvasPaneOpen":false,"CanvasPosition":"Right"}}""" + $"""{{"Repos":[{{"RepoId":{{"RepoId":"TestRepo"}},"RootFolderName":"TestRepo","Worktrees":[{wts}],"IsReady":true,"BaseBranch":"main"}}],"SchedulerEvents":[],"LatestByCategory":{{}},"AppVersion":"test","EditorName":"","CollapsedRepos":[],"CanvasPaneOpen":false,"CanvasPosition":"Right","CanvasSize":"Ratio1To1"}}""" let emptySyncStatus = "{}"