Files
DeepSeek-Reasonix/desktop/next/main.go
T
yhh b18d8ef3aa feat(studio): storage relocation, composer intake, and the checkpoint agreement
Accumulated work from the branch, carried across the rebase onto origin/studio
and merged with what landed there in the meantime.

Storage: a workspace can name where its state lives, and the server tracks the
one relocation it may be running (internal/storage, config/storage_roots,
serve/storage, the Storage panel).

Composer intake: what a drop or a long paste becomes is one pure function —
payload in, action out — so "what happens if I drop a folder" is a test rather
than a branch of an onDrop handler. It sits on the drop router that landed
upstream: that one answers which pane a drop hit, this one answers what it
means and says so before the pointer is released. A paste long enough to bury
the composer becomes a chip beside it instead.

Session: the checkpoint agreement and the lineage/torn-log recovery cases,
plus the conflict diagnostic learning where two transcripts parted and which
roles stood there — a position and a role tell a local reshape from a foreign
write without recording any content.

Model refs: the default-model write checks the live catalog, so an extension
model the runtime knows can be persisted as the default.
2026-08-19 23:52:04 -07:00

474 lines
17 KiB
Go

// Command reasonix-studio is the Wails shell for frontend-next. It differs from
// the existing desktop binary in one way that matters: the UI talks to the
// kernel over internal/serve's HTTP surface instead of Wails bindings, so the
// same build runs in a browser against `reasonix serve`.
package main
import (
"context"
"fmt"
"io/fs"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"reasonix/internal/i18n"
goruntime "runtime"
"strings"
"sync"
"time"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/mac"
"github.com/wailsapp/wails/v2/pkg/options/windows"
"github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/desktop/internal/update"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/event"
"reasonix/internal/notify"
"reasonix/internal/serve"
// Kinds register from init, so a binary builds only what it links. Without
// these the shell answers every Anthropic model with "unknown kind" at
// switch time; openai alone arrived, pulled in transitively by config.
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
_ "reasonix/internal/provider/responses"
)
// Everything the SPA is allowed to route to the kernel. Anything else falls
// through to the static assets, so an unknown path renders index.html rather
// than reaching the controller.
// Must match the name and path SsePort uses when it detects the Wails runtime.
const (
wailsEventName = "rx:event"
replayPath = "/rx-replay"
// Must match runtimePrefix in internal/serve and the frontend's pane base.
runtimePrefix = "/rt/"
)
var apiPaths = map[string]bool{
"/events": true, "/events/replay": true, "/history": true, "/context": true, "/status": true,
"/sessions": true, "/checkpoints": true, "/branches": true, "/models": true,
"/submit": true, "/cancel": true, "/approve": true, "/answer": true,
"/plan": true, "/preset": true, "/model": true, "/effort": true,
"/goal": true, "/resume": true, "/compact": true, "/new": true,
"/rewind": true, "/fork": true, "/summarize": true, "/forget": true,
"/tool-approval-mode": true, "/auto-approve-tools": true, "/bypass": true,
"/provider-setup": true, "/delete-session": true, "/inbox": true, "/inbox/items": true,
"/trajectory": true, "/slash": true, "/complete": true,
"/workspace": true, "/workspaces": true,
"/mcp": true, "/skills": true, "/capability-scope": true, "/account": true, "/hooks": true,
"/memory": true, "/network": true, "/shell": true, "/todos": true, "/providers": true,
"/changes": true, "/attachments": true, "/drop": true, "/roles": true,
"/themes": true, "/extensions": true, "/plugins": true, "/surfaces": true,
"/welcome": true, "/appearance": true,
"/permissions": true, "/sandbox": true, "/storage": true,
}
// A sub-path belongs to the resource it hangs off: /mcp/reconnect is the same
// surface as /mcp. Listing families rather than every leaf is what keeps a new
// endpoint from silently answering with index.html instead of JSON — and
// TestEveryPathTheFrontendCallsIsRouted is what keeps this list honest, because
// the comment alone did not.
var apiPrefixes = []string{"/mcp/", "/skills/", "/inbox/", "/account/", "/hooks/", "/memory/", "/network/", "/providers/", "/rewind/", "/extensions/", "/themes/", "/plugins/", "/appearance/", "/storage/"}
// splitRuntimePath separates a pane's address from the route it is asking for:
// /rt/r2/status is runtime r2 asking for /status. An unprefixed path belongs to
// no pane in particular and reaches the hub's first runtime.
func splitRuntimePath(p string) (id, path string) {
if !strings.HasPrefix(p, runtimePrefix) {
return "", p
}
rest := strings.TrimPrefix(p, runtimePrefix)
id, path, found := strings.Cut(rest, "/")
if !found {
return id, "/"
}
return id, "/" + path
}
// isHubPath covers the routes that belong to the hub rather than to any one
// runtime: the pane list and the workspace tree the sidebar reads.
func isHubPath(p string) bool {
p = strings.TrimSuffix(p, "/")
return p == "/runtimes" || strings.HasPrefix(p, "/runtimes/") ||
p == "/tree" || strings.HasPrefix(p, "/tree/")
}
func isAPIPath(p string) bool {
p = strings.TrimSuffix(p, "/")
if apiPaths[p] {
return true
}
for _, prefix := range apiPrefixes {
if strings.HasPrefix(p+"/", prefix) && p+"/" != prefix {
return true
}
}
return false
}
func main() {
// A macOS install re-executes this binary to swap the bundle after the old
// process exits. That child must never reach run(), or the swap would race a
// second Studio booting on top of the directory it is replacing.
if handled, code := update.MaybeRunMacHandoff(os.Args[1:]); handled {
os.Exit(code)
}
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "reasonix-next:", err)
os.Exit(1)
}
}
func run() error {
ctx := context.Background()
cfg, err := config.Load()
if err != nil {
return err
}
// This window is the only client of its kernel, so a system notification
// reaches the person who asked for it. A networked server leaves this off.
notifySink := windowNotifications(cfg)
bc := serve.NewBroadcaster()
// A window opens where it was left, not where its shortcut happened to point.
root := boot.ResolveWorkspaceRoot(lastWorkspace())
built, err := boot.BuildRuntime(ctx, boot.Options{
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: notifySink(bc),
Stderr: os.Stderr,
})
if err != nil {
return err
}
ctrl := built.Controller
// No EnsureSessionPath here: minting the file at launch left one empty
// transcript behind every time the window opened. The first turn creates
// it, and the inbox ensures its own path when it enqueues.
// Ask/Auto/YOLO is a posture the user set on the composer, not a per-launch
// default — the old shell has read this since it had a picker.
ctrl.SetToolApprovalMode(cfg.DesktopDefaultToolApprovalMode())
// Native panels the shell opens are outside the webview, so the frontend's
// catalogue cannot reach them. They follow the desktop interface language, a
// separate setting from the kernel's — hence a catalogue read, not the active one.
shell := &App{pumps: map[string]context.CancelFunc{}, say: i18n.Catalog(cfg.DesktopLanguage())}
// One hub, several panes: each session gets its own runtime, so a second
// conversation runs beside the first instead of rebuilding it.
hub := serve.NewHub(serve.HubOptions{
Serve: cfg.Serve,
Grant: grantWindowCapabilities,
DecorateSink: notifySink,
OnOpen: shell.startPump,
OnClose: shell.stopPump,
})
shell.hub = hub
srv := serve.New(ctrl, bc, cfg.Serve)
srv.AdoptRuntime(built)
// Without this a past save-conflict loop's forks stay on disk forever: the
// CLI and the old shell each sweep them, this window is the third host.
srv.StartRecoveryGC(ctx)
if err := adoptWindowPane(hub, srv, bc, ctrl); err != nil {
return err
}
api := hub.Handler()
defer hub.Shutdown()
assets, err := frontendAssets()
if err != nil {
return err
}
return wails.Run(&options.App{
Title: "Reasonix Studio",
Width: 1440,
Height: 900,
// The top row is the title bar — project, goal, preset — so a native one
// above it costs a whole row to say the app's own name. Same treatment
// the existing shell uses; Linux keeps its frame, WebKitGTK has no inset.
Frameless: goruntime.GOOS == "windows",
// Shown by fitWindow once it has been measured against the screen it
// landed on. Sizing a visible window makes the correction a flicker.
StartHidden: true,
Mac: &mac.Options{TitleBar: mac.TitleBarHiddenInset(), Appearance: mac.DefaultAppearance},
Windows: &windows.Options{Theme: windows.SystemDefault},
MinWidth: 760,
MinHeight: 480,
Menu: appMenu(),
// Production builds ship without one, so the window had no copy, paste,
// or select-all on right-click — in a text editor that reads as broken.
EnableDefaultContextMenu: true,
Bind: []any{shell},
DragAndDrop: dragAndDrop(),
OnStartup: func(ctx context.Context) {
shell.mu.Lock()
shell.ctx = ctx
shell.mu.Unlock()
applyDockIcon()
// Before anything is drawn: a window that does not fit takes its own
// title bar off-screen on Windows, and with it the way to move it.
fitWindow(ctx)
// Panes opened before the window came up have no pump yet; from here
// on the hub's OnOpen starts one as each is published.
for _, rt := range hub.Runtimes() {
shell.startPump(rt)
}
},
AssetServer: &assetserver.Options{
Assets: assets,
// In-process HTTP: no port, no CORS, no second transport to keep in
// sync with the browser build.
Middleware: hubMiddleware(hub, api),
},
// The window is held open across the shutdown rather than vanishing into
// one; see closing.go. OnShutdown stays as the backstop for exits that
// never pass the close button (a signal, a quit from the dock menu).
OnBeforeClose: shell.beginClose,
OnShutdown: func(context.Context) { hub.Shutdown() },
})
}
func hubMiddleware(hub *serve.Hub, api http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, path := splitRuntimePath(r.URL.Path)
// The bus has no reconnect handshake, so a reloaded page asks for the
// replay resubscribing to /events would have given it.
if path == replayPath {
if rt := hub.Get(id); rt != nil {
rt.Server.Controller().ReplayPendingPromptsWith(func() event.Sink { return rt.Events })
}
w.WriteHeader(http.StatusNoContent)
return
}
if isAPIPath(path) || isHubPath(path) {
api.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
}
// appMenu is what makes ⌘C work: a WKWebView takes its editing shortcuts from
// the application's Edit menu, and a window without one has no copy, paste, or
// undo at all. macOS only — elsewhere the same bar renders as a stray
// in-window strip, and those platforms bind the shortcuts themselves.
func appMenu() *menu.Menu {
if goruntime.GOOS != "darwin" {
return nil
}
m := menu.NewMenu()
m.Append(menu.AppMenu())
m.Append(menu.EditMenu())
m.Append(menu.WindowMenu())
return m
}
// App is the one thing the SPA cannot do over HTTP: open the platform's folder
// picker. Everything else it needs is a route on the embedded hub. It also owns
// the per-runtime event pumps, because the bus is the window's, not a server's.
type App struct {
hub *serve.Hub
ctx context.Context
// Text for the native panels this shell opens. Read once at launch from the
// desktop interface language; the kernel's own catalogue is a different
// setting and must not be swapped out for this.
say i18n.Messages
mu sync.Mutex
pumps map[string]context.CancelFunc
// See closing.go: the window outlives the close button by however long the
// session takes to reach disk.
closing closeState
}
// windowNotifications returns the sink wrapper every runtime in this window
// gets. Off by default and per the shared [notifications] config, so the CLI
// and the window answer to one setting rather than each growing its own.
func windowNotifications(cfg *config.Config) func(event.Sink) event.Sink {
if cfg == nil || !cfg.Notifications.Enabled {
return func(sink event.Sink) event.Sink { return sink }
}
sender := notify.NewPlatformSender()
return func(sink event.Sink) event.Sink {
return notify.NewSink(sink, sender, cfg.Notifications)
}
}
// grantWindowCapabilities opens up what only a local window may do. The single
// client is the person in front of it, so the folder picker, the account token
// and provider keys are local dialogs rather than remote capabilities — and
// every pane gets them, not just the one the window started with.
func grantWindowCapabilities(srv *serve.Server) {
srv.AllowWorkspaceSwitch()
srv.AllowAccountAuth()
srv.AllowProviderEdit()
// Without this the first-run connection step never appears: /provider-setup
// answers 404 while it is off, the window reads that as "nothing to set up",
// and a machine with no key lands in the composer where every turn fails.
srv.EnableProviderSetup()
}
// startPump forwards one runtime's frames onto the Wails bus under its own
// event name. Before OnStartup there is no context to emit into; the shell
// pumps whatever the hub already holds once the window comes up.
func (a *App) startPump(rt *serve.Runtime) {
if rt == nil {
return
}
a.mu.Lock()
defer a.mu.Unlock()
if a.ctx == nil || a.pumps[rt.ID] != nil {
return
}
ctx, cancel := context.WithCancel(a.ctx)
a.pumps[rt.ID] = cancel
go pumpEvents(ctx, rt)
}
func (a *App) stopPump(rt *serve.Runtime) {
if rt == nil {
return
}
a.mu.Lock()
defer a.mu.Unlock()
if cancel := a.pumps[rt.ID]; cancel != nil {
cancel()
delete(a.pumps, rt.ID)
}
}
// currentRoot is the folder the picker should open at: whichever pane the hub
// lists first. A window with no pane left has nothing to anchor to.
func (a *App) currentRoot() string {
for _, rt := range a.hub.Runtimes() {
if root := rt.Server.Controller().WorkspaceRoot(); root != "" {
return root
}
}
return ""
}
// PickWorkspace returns the folder the user chose, or "" if they cancelled.
// Choosing does not switch anything — the SPA posts the path back to
// /workspace, so the browser build reaches the same code by typing one.
func (a *App) PickWorkspace() (string, error) {
if a.ctx == nil {
return "", nil
}
// The panel can make a folder — that is what CanCreateDirectories is for —
// but nothing said so, and a title reading "open" describes picking one that
// exists. Users concluded the app could only open projects, never start one.
opts := runtime.OpenDialogOptions{Title: a.say.PickWorkspaceTitle, CanCreateDirectories: true}
// Wails refuses to open the panel at all when this points at nothing, and
// answers with an error instead — a workspace that has since been moved
// would take the picker down with it.
if root := a.currentRoot(); root != "" {
if st, err := os.Stat(root); err == nil && st.IsDir() {
opts.DefaultDirectory = root
}
}
dir, err := runtime.OpenDirectoryDialog(a.ctx, opts)
if err != nil {
slog.Warn("studio: folder picker", "err", err)
return "", err
}
return dir, nil
}
// OpenExternal hands a link to the platform browser. A WKWebView answers a
// target="_blank" click with nothing at all — Wails binds no delegate for it —
// so every link in the window is dead until something routes it out. http(s)
// only: these come from model output, which may not reach the OS opener with a
// scheme of its choosing.
func (a *App) OpenExternal(rawURL string) {
if a.ctx == nil {
return
}
u, err := url.Parse(rawURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
slog.Warn("studio: refused to open a link", "url", rawURL)
return
}
runtime.BrowserOpenURL(a.ctx, u.String())
}
// lastWorkspace is the folder this shell was driving when it last closed, or ""
// to let boot resolve one from the process working directory.
func lastWorkspace() string {
for _, dir := range serve.Workspaces() {
if st, err := os.Stat(dir); err == nil && st.IsDir() {
return dir
}
}
return ""
}
// The asset server buffers a response until its handler returns, so GET /events
// never delivers a byte inside the shell — the SSE handler is still streaming
// when the page gives up. Wails' own bus is the one channel that does push, so
// the shell forwards the same broadcaster frames the browser reads over SSE.
// The payload is byte-identical either way; only the transport differs.
func pumpEvents(ctx context.Context, rt *serve.Runtime) {
var ch <-chan serve.Frame
var unsubscribe func()
// The same handoff GET /events performs: subscribe and replay as one step,
// so a prompt already waiting for an answer survives the handover.
rt.Server.Controller().ReplayPendingPromptsWith(func() event.Sink {
ch, unsubscribe = rt.Events.Subscribe()
return event.FuncSink(func(e event.Event) { rt.Events.EmitTo(ch, e) })
})
defer unsubscribe()
name := runtimeEventName(rt.ID)
// The bus has nothing resembling a connection, so a page that missed a frame
// has no reconnect to notice it by. Same watermark the SSE keepalive sends,
// for the same reason: a loss at the end of a turn is otherwise invisible.
watermark := time.NewTicker(serve.SSEWatermarkInterval)
defer watermark.Stop()
for {
select {
case f, ok := <-ch:
if !ok {
return
}
runtime.EventsEmit(ctx, name, string(f.Data))
case <-watermark.C:
runtime.EventsEmit(ctx, name, fmt.Sprintf(`{"kind":"stream_watermark","seq":%d}`, rt.Events.Watermark()))
case <-ctx.Done():
return
}
}
}
// runtimeEventName is the bus channel one pane listens on. Panes run at the
// same time, so a single channel would interleave two conversations into both.
func runtimeEventName(id string) string { return wailsEventName + ":" + id }
func frontendAssets() (fs.FS, error) {
exe, err := os.Executable()
if err != nil {
return nil, err
}
for _, dir := range []string{
filepath.Join(filepath.Dir(exe), "frontend-next", "dist"),
filepath.Join("..", "frontend-next", "dist"),
filepath.Join("frontend-next", "dist"),
} {
if st, err := os.Stat(filepath.Join(dir, "index.html")); err == nil && !st.IsDir() {
return os.DirFS(dir), nil
}
}
return nil, fmt.Errorf("frontend-next/dist not found: run `pnpm build` in desktop/frontend-next first")
}