Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/spec/canvas-pane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion src/Client/App.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -696,14 +699,19 @@ 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}"
| false -> "dashboard"

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 =
Expand Down
1 change: 1 addition & 0 deletions src/Client/AppTypes.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 22 additions & 4 deletions src/Client/CanvasPane.fs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ let private overviewView (repos: RepoModel list) (bridgeLiveness: Map<string, Br
/// silent argument transposition compile and surface only at runtime.
type CanvasPaneCallbacks =
{ SetPosition: CanvasPosition -> unit
SetSize: CanvasSize -> unit
SelectDoc: string -> unit
OnOverviewClick: string -> unit
OnOverviewDocClick: string -> string -> unit
Expand All @@ -154,23 +155,27 @@ 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<string, BridgeLiveness>) (unviewedFilenames: Set<string>) (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<string, BridgeLiveness>) (unviewedFilenames: Set<string>) (visitedDocs: string list) (callbacks: CanvasPaneCallbacks) =
let { SetPosition = setPosition
SetSize = setSize
SelectDoc = selectDoc
OnOverviewClick = onOverviewClick
OnOverviewDocClick = onOverviewDocClick
ArchiveDoc = archiveDoc
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"
Expand All @@ -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"
Expand Down Expand Up @@ -209,6 +226,7 @@ let view (isOpen: bool) (position: CanvasPosition) (focusedDoc: (WorktreeStatus
prop.children [ ArchiveViews.archiveIcon ]
]
| _ -> ()
sizeButtons
positionButtons
]
]
Expand Down
2 changes: 2 additions & 0 deletions src/Client/CanvasState.fs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ open Elmish
type CanvasState =
{ CanvasPaneOpen: bool
CanvasPosition: CanvasPosition
CanvasSize: CanvasSize
ActiveCanvasDoc: Map<string, string>
VisitedCanvasDocs: Map<string, string list>
LastViewedHashes: Map<string, Map<string, string>>
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/Client/CanvasUpdate.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/Client/CanvasView.fs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ let view (model: Model) (dispatch: Dispatch<Msg>) =

let canvasCallbacks: CanvasPane.CanvasPaneCallbacks =
{ SetPosition = SetCanvasPosition >> dispatch
SetSize = SetCanvasSize >> dispatch
SelectDoc = selectCanvasDoc
OnOverviewClick = onOverviewClick
OnOverviewDocClick = onOverviewDocClick
Expand All @@ -74,4 +75,4 @@ let view (model: Model) (dispatch: Dispatch<Msg>) =
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
16 changes: 11 additions & 5 deletions src/Client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/Server/DemoFixture.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions src/Server/WorktreeApi.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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}" }
Expand Down Expand Up @@ -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<string, Map<string, string>> =
withConfigDocument Map.empty (fun root ->
match root.TryGetProperty("lastViewedHashes") with
Expand Down Expand Up @@ -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<bool>) (wtPath: WorktreePath) =
Expand Down Expand Up @@ -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" ] }
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 11 additions & 1 deletion src/Shared/Types.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
[<RequireQualifiedAccess>]
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)
Expand Down Expand Up @@ -239,7 +247,8 @@ type DashboardResponse =
EditorName: string
CollapsedRepos: Set<RepoId>
CanvasPaneOpen: bool
CanvasPosition: CanvasPosition }
CanvasPosition: CanvasPosition
CanvasSize: CanvasSize }

type FixtureData =
{ Worktrees: DashboardResponse
Expand Down Expand Up @@ -270,6 +279,7 @@ type IWorktreeApi =
saveCollapsedRepos: RepoId list -> Async<unit>
saveCanvasPaneOpen: bool -> Async<unit>
saveCanvasPosition: CanvasPosition -> Async<unit>
saveCanvasSize: CanvasSize -> Async<unit>
resumeSession: WorktreePath -> Async<Result<unit, string>>
sendCanvasMessage: CanvasMessageRequest -> Async<CanvasMessageResult>
archiveCanvasDoc: ArchiveCanvasDocRequest -> Async<Result<unit, string>>
Expand Down
2 changes: 1 addition & 1 deletion src/Tests/ArchiveTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "{}"

Expand Down
Loading