feat(studio): a launch with no chosen or remembered project opens the app's own auto workspace, not home

This commit is contained in:
esengine committed 2026-10-24 08:39:37 -07:00
1 parent 7c22c5340e
commit 087ea05686
6 files changed
+142 -13

No files matched your search

+8 -7
View File
@@ -6,19 +6,20 @@ import (
"strings"
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/config"
"reasonix/internal/frontend/serve"
)
// launchWorkspace is the folder the first pane opens. The working directory
// counts only when someone chose it: a Start-menu or Finder launch inherits the
// launchWorkspace is the folder the first pane opens: a chosen directory, then
// a remembered project, then the app's own auto workspace, never the home
// directory. The working directory counts only when someone chose it: a Start-menu or Finder launch inherits the
// install directory or the filesystem root, and taking that as a project puts
// the app's own folder in the sidebar again after every removal.
func launchWorkspace(appExe string) string {
home, _ := os.UserHomeDir()
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), home)
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), config.AutoWorkspaceDir())
}
func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) string {
func pickLaunchWorkspace(cwd, appExe string, remembered []string, auto string) string {
if userChoseDir(cwd, appExe) {
return cwd
}
@@ -27,8 +28,8 @@ func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) s
return dir
}
}
if home != "" {
return home
if auto != "" && os.MkdirAll(auto, 0o755) == nil {
return auto
}
return cwd
}
+59 -4
View File
@@ -4,7 +4,10 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/contract/config"
)
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
@@ -40,7 +43,8 @@ func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
}
return d
}
a, b, home := mk("a"), mk("b"), mk("home")
a, b := mk("a"), mk("b")
auto := filepath.Join(base, "auto")
gone := filepath.Join(base, "gone")
proj := mk("proj")
@@ -53,11 +57,11 @@ func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
{"chosen directory wins over the list", proj, []string{a}, proj},
{"list head", app, []string{a, b}, a},
{"stale head skipped", app, []string{gone, b}, b},
{"all stale falls to home", app, []string{gone}, home},
{"empty list falls to home", app, nil, home},
{"all stale falls to the auto workspace", app, []string{gone}, auto},
{"empty list falls to the auto workspace", app, nil, auto},
}
for _, c := range cases {
if got := pickLaunchWorkspace(c.cwd, exe, c.remembered, home); got != c.want {
if got := pickLaunchWorkspace(c.cwd, exe, c.remembered, auto); got != c.want {
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
}
}
@@ -90,3 +94,54 @@ func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
t.Fatalf("missing launch project = %q, want next remembered folder %q", got, b)
}
}
func TestLaunchWithNothingChosenOrRememberedOpensTheAutoWorkspaceNotHome(t *testing.T) {
base := t.TempDir()
state := filepath.Join(base, "state")
home := filepath.Join(base, "home")
app := filepath.Join(base, "app")
for _, dir := range []string{state, home, app} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("REASONIX_HOME", state)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Chdir(app)
exe := filepath.Join(app, "Reasonix Studio")
got := launchWorkspace(exe)
if got == home {
t.Fatalf("a first launch opened the home directory %q", got)
}
if want := config.AutoWorkspaceDir(); got != want {
t.Fatalf("launchWorkspace = %q, want the auto workspace %q", got, want)
}
if st, err := os.Stat(got); err != nil || !st.IsDir() {
t.Fatalf("the auto workspace was not created: %v", err)
}
if rel, err := filepath.Rel(state, got); err != nil || strings.HasPrefix(rel, "..") {
t.Fatalf("the auto workspace %q is not under the state root %q", got, state)
}
}
func TestPickLaunchWorkspaceCreatesTheAutoWorkspaceLast(t *testing.T) {
base := t.TempDir()
app := filepath.Join(base, "app")
exe := filepath.Join(app, "Reasonix Studio")
auto := filepath.Join(base, "state", "workspaces", "auto")
if got := pickLaunchWorkspace(app, exe, nil, auto); got != auto {
t.Fatalf("got %q, want %q", got, auto)
}
if st, err := os.Stat(auto); err != nil || !st.IsDir() {
t.Fatalf("not created: %v", err)
}
chosen := filepath.Join(base, "proj")
if err := os.MkdirAll(chosen, 0o755); err != nil {
t.Fatal(err)
}
if got := pickLaunchWorkspace(chosen, exe, nil, auto); got != chosen {
t.Fatalf("a chosen directory must win over the auto workspace: %q", got)
}
}
+11
View File
@@ -555,6 +555,17 @@ func MemoryUserDir() string { return processRoots().MemoryUserDir() }
// MemoryUserDir is the user state root this binding resolves to.
func (r Roots) MemoryUserDir() string { return r.userSupportDir() }
// AutoWorkspaceDir is the folder a first launch opens when nobody has chosen a
// project: a small one the app owns under the user state root, never the home
// directory. The caller creates it.
func AutoWorkspaceDir() string {
base := processRoots().MemoryUserDir()
if base == "" {
return ""
}
return filepath.Join(base, "workspaces", "auto")
}
// ConventionDirs are the parent directories scanned for agent assets (skills,
// commands), in canonical-first order. .reasonix is ours; .agents / .agent /
// .claude let users drop in assets authored for other agent tools without moving
@@ -0,0 +1,53 @@
package serve
import (
"context"
"os"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
)
// The auto workspace is where an unchosen launch opens, not a project anyone
// picked: if the list remembered it, it would become the answer to every later
// launch and sit in the sidebar as a project.
func TestTheAutoWorkspaceIsNeverRemembered(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
auto := config.AutoWorkspaceDir()
if err := os.MkdirAll(auto, 0o755); err != nil {
t.Fatal(err)
}
project := filepath.Join(testenv.TempDir(t), "project")
if err := os.MkdirAll(project, 0o755); err != nil {
t.Fatal(err)
}
if err := addRememberedWorkspace(context.Background(), auto); err != nil {
t.Fatal(err)
}
keepUsedWorkspace(auto)
if got := LaunchWorkspaces(); len(got) != 0 {
t.Fatalf("the auto workspace was remembered: %v", got)
}
if err := addRememberedWorkspace(context.Background(), project); err != nil {
t.Fatal(err)
}
if got := LaunchWorkspaces(); len(got) != 1 || got[0] != project {
t.Fatalf("a chosen project must still be remembered: %v", got)
}
}
// What is said in an auto-workspace conversation is kept in the user state
// root, with the conversations of every other folder: nothing about it lives in
// a directory that can be deleted out from under it.
func TestAutoWorkspaceConversationsAreKeptUnderTheStateRoot(t *testing.T) {
state := testenv.TempDir(t)
t.Setenv("REASONIX_HOME", state)
dir := SessionDirFor(config.AutoWorkspaceDir())
if rel, err := filepath.Rel(state, dir); err != nil || len(rel) == 0 || rel[0] == '.' {
t.Fatalf("session dir %q is not under the state root %q", dir, state)
}
}
+9 -2
View File
@@ -58,6 +58,13 @@ func updateWorkspaceList(ctx context.Context, repair bool, mutate func(*workspac
return workspacelist.Update(ctx, workspacesPath(), repair, mutate)
}
// isAutoWorkspace says whether dir is the folder the app opens when nobody chose
// one. It is where such a launch lands, not a project, so no list holds it.
func isAutoWorkspace(dir string) bool {
auto := config.AutoWorkspaceDir()
return auto != "" && projectIdentity(dir) == projectIdentity(auto)
}
func rememberWorkspace(dir string) {
if err := addRememberedWorkspace(context.Background(), dir); err != nil {
slog.Warn("serve: remember workspace", "err", err)
@@ -65,7 +72,7 @@ func rememberWorkspace(dir string) {
}
func addRememberedWorkspace(ctx context.Context, dir string) error {
if dir == "" {
if dir == "" || isAutoWorkspace(dir) {
return nil
}
return updateWorkspaceList(ctx, true, func(list *workspaceList) error {
@@ -92,7 +99,7 @@ func addRememberedWorkspace(ctx context.Context, dir string) error {
// adopted at launch is no folder anyone picked, and its conversations are
// reachable only through a row for it.
func keepUsedWorkspace(dir string) {
if dir == "" {
if dir == "" || isAutoWorkspace(dir) {
return
}
err := updateWorkspaceList(context.Background(), true, func(list *workspaceList) error {
@@ -0,0 +1,2 @@
- 改进:没有选过项目也没有记住的项目时,首次启动不再打开用户主目录,而是打开应用自己管理的一个小目录;它不会被记成项目。关联 #14363。
- Improved: with no chosen or remembered project, a first launch opens a small folder the app owns instead of your home directory, and that folder is never remembered as a project. Refs #14363.