diff --git a/internal/command/web.go b/internal/command/web.go index 366a899..3ee9362 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -427,7 +427,13 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo // that may be headless) exclude them. An agent in an automation run that calls // ask_user would otherwise block on the WS channel forever (no client resolves // it) and stall the run until the liveness ceiling cancels it. - buildWebTask := func(taskID, taskPwd, modeStr string, exec tools.RemoteExecutor, excludeInteractive bool) (*web.EngineConfig, error) { + buildWebTask := func( + taskID, taskPwd, modeStr string, + exec tools.RemoteExecutor, + excludeInteractive bool, + workspaceKind session.WorkspaceKind, + ) (*web.EngineConfig, error) { + scratch := exec == nil && session.NormalizeWorkspaceKind(workspaceKind) == session.WorkspaceScratch // Per-task config snapshot, so a live task is insulated from mid-run // edits (the shared copy in internal/web is guarded by cfgMu). In setup // mode there is no valid config on disk yet — LoadConfig always fails — @@ -446,11 +452,11 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo if modeStr != "" { startMode = mode.Parse(modeStr) } - if exec == nil { // project config overlay (local tasks only) + if exec == nil && !scratch { // project config overlay (project-bound local tasks only) config.ApplyProjectOverlay(taskCfg, taskPwd) } else { - // Remote tasks skip project config (can't read .jcode/ remotely) - // but env vars are local process state and always apply. + // Remote and scratch tasks skip project config. Environment variables + // are user-owned process state and still apply to both. config.ApplyEnvOverlay(taskCfg) } // Fresh execution environment for this task only. @@ -473,7 +479,9 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo envLabel = fmt.Sprintf("%s (pwd: %s)", exec.Label(), taskPwd) projectKey = exec.ProjectLabel(taskPwd) } else { - taskLoader.ScanProjectSkills(taskPwd) + if !scratch { + taskLoader.ScanProjectSkills(taskPwd) + } taskEnvInfo = util.CollectEnvInfo(taskPwd) } @@ -481,12 +489,15 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo // shared with the workflow_run tool so slash triggers and inline runs // resolve the same set. Project workflows only apply to a local exec. taskFlowLoader := flow.NewLoader() - if exec == nil { + if exec == nil && !scratch { taskFlowLoader.LoadProject(taskPwd) } tbg := tools.NewBackgroundManager(tenv) trec, _ := session.NewRecorder(projectKey, providerName, modelName) + if trec != nil { + trec.SetWorkspaceKind(workspaceKind) + } if taskID != "" && trec != nil { trec.SetUUID(taskID) } @@ -559,7 +570,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo // on the remote host — the memory store and session index are keyed to // the local machine, so a remote path would just create a junk scope // and never match any sessions. - if exec == nil { + if exec == nil && !scratch { mempipeline.MaybeStartBackground(taskCfg, taskPwd) } @@ -575,7 +586,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo return prompts.GetSystemPrompt(platform, taskPwd, "local", taskEnvInfo, skillDescs), prompts.GetPlanSystemPrompt(platform, taskPwd, "local", taskEnvInfo) } - providerRuntimeLoader := webProviderRuntimeConfigLoader(taskPwd, exec != nil) + providerRuntimeLoader := webProviderRuntimeConfigLoader(taskPwd, exec != nil || scratch) // Snapshot and wrap process-wide raw MCP endpoints for this task. The // ledger is created once above and reused across every model/mode/config @@ -686,7 +697,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo config.Logger().Printf("[image] web generate_image unavailable: %v", imageErr) } } - if config.MemoryEnabled(agentCfg) { + if config.MemoryEnabled(agentCfg) && !scratch { all = append(all, tenv.NewMemoryNoteTool(&tools.MemoryNoteDeps{ SessionIDFn: func() string { if trec != nil { @@ -764,7 +775,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo if loadErr != nil { return nil, fmt.Errorf("reload agent config: %w", loadErr) } - if exec == nil { + if exec == nil && !scratch { config.ApplyProjectOverlay(agentCfg, taskPwd) } else { config.ApplyEnvOverlay(agentCfg) @@ -917,7 +928,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo if loadErr != nil { return web.ToolSearchCounts{} } - if exec == nil { + if exec == nil && !scratch { config.ApplyProjectOverlay(agentCfg, taskPwd) } else { config.ApplyEnvOverlay(agentCfg) @@ -1047,7 +1058,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo } currentCfg, currentCfgErr := config.LoadConfig() if currentCfgErr == nil { - if exec == nil { + if exec == nil && !scratch { config.ApplyProjectOverlay(currentCfg, taskPwd) } else { config.ApplyEnvOverlay(currentCfg) @@ -1096,6 +1107,7 @@ func runWebServer(parent context.Context, port int, host string, openBrowser boo return &web.EngineConfig{ TaskID: taskID, Pwd: taskPwd, + WorkspaceKind: session.NormalizeWorkspaceKind(workspaceKind), Mode: startMode.String(), ProviderName: providerName, ModelName: modelName, @@ -1151,6 +1163,7 @@ type webTaskBuilder func( taskID, taskPwd, modeStr string, exec tools.RemoteExecutor, excludeInteractive bool, + workspaceKind session.WorkspaceKind, ) (*web.EngineConfig, error) type webServerRuntime struct { @@ -1193,7 +1206,9 @@ func startWebServer(runtime webServerRuntime) error { } cloudSup := newCloudSupervisor(runtime.cfg, runtime.port, webToken) - bootEC, err := runtime.buildTask("", runtime.pwd, runtime.startupMode, nil, false) + bootEC, err := runtime.buildTask( + "", runtime.pwd, runtime.startupMode, nil, false, session.WorkspaceProject, + ) if err != nil { return err } @@ -1210,16 +1225,20 @@ func startWebServer(runtime webServerRuntime) error { RebuildForMode: bootEC.RebuildForMode, RebuildForRole: bootEC.RebuildForRole, NewEngine: func(taskID, taskPwd, modeStr string) (*web.EngineConfig, error) { - return runtime.buildTask(taskID, taskPwd, modeStr, nil, false) + return runtime.buildTask(taskID, taskPwd, modeStr, nil, false, session.WorkspaceProject) + }, + NewScratchEngine: func(taskID, taskPwd, modeStr string) (*web.EngineConfig, error) { + return runtime.buildTask(taskID, taskPwd, modeStr, nil, false, session.WorkspaceScratch) }, NewRemoteEngine: func( taskID string, exec tools.RemoteExecutor, remotePwd, modeStr string, ) (*web.EngineConfig, error) { - return runtime.buildTask(taskID, remotePwd, modeStr, exec, false) + return runtime.buildTask(taskID, remotePwd, modeStr, exec, false, session.WorkspaceProject) }, NewAutomationEngine: func(taskID, taskPwd, modeStr string) (*web.EngineConfig, error) { - return runtime.buildTask(taskID, taskPwd, modeStr, nil, true) + return runtime.buildTask(taskID, taskPwd, modeStr, nil, true, session.WorkspaceProject) }, + WorkspaceKind: session.WorkspaceProject, InitialMode: runtime.startupMode, TodoStore: bootEC.TodoStore, Recorder: bootEC.Recorder, diff --git a/internal/session/session.go b/internal/session/session.go index 99a53ab..d956fab 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -261,19 +261,20 @@ type EntryImage struct { // Entry is one line of the JSONL session file. type Entry struct { - Type EntryType `json:"type"` - UUID string `json:"uuid,omitempty"` - Project string `json:"project,omitempty"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - Agent string `json:"agent,omitempty"` - Content string `json:"content,omitempty"` - Name string `json:"name,omitempty"` // tool name - Args string `json:"args,omitempty"` // tool args JSON - Output string `json:"output,omitempty"` // tool output - Error string `json:"error,omitempty"` // tool error - ToolCallID string `json:"tool_call_id,omitempty"` // links tool_call ↔ tool_result - Timestamp string `json:"timestamp"` + Type EntryType `json:"type"` + UUID string `json:"uuid,omitempty"` + Project string `json:"project,omitempty"` + WorkspaceKind WorkspaceKind `json:"workspace_kind,omitempty"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Agent string `json:"agent,omitempty"` + Content string `json:"content,omitempty"` + Name string `json:"name,omitempty"` // tool name + Args string `json:"args,omitempty"` // tool args JSON + Output string `json:"output,omitempty"` // tool output + Error string `json:"error,omitempty"` // tool error + ToolCallID string `json:"tool_call_id,omitempty"` // links tool_call ↔ tool_result + Timestamp string `json:"timestamp"` // Typed tool/generation correlation. These additive fields are shared by // operation journal and enhanced tool-result entries. OperationID string `json:"operation_id,omitempty"` @@ -391,13 +392,14 @@ type Entry struct { // SessionMeta is stored in the index for fast listing. type SessionMeta struct { - UUID string `json:"uuid"` - Project string `json:"project"` - Provider string `json:"provider"` - Model string `json:"model"` - Agent string `json:"agent,omitempty"` - StartTime string `json:"start_time"` // RFC3339 - Title string `json:"title,omitempty"` + UUID string `json:"uuid"` + Project string `json:"project"` + WorkspaceKind WorkspaceKind `json:"workspace_kind,omitempty"` + Provider string `json:"provider"` + Model string `json:"model"` + Agent string `json:"agent,omitempty"` + StartTime string `json:"start_time"` // RFC3339 + Title string `json:"title,omitempty"` // Task metadata. Additive — legacy index files simply lack these keys, which // unmarshal to zero values (not pinned / not archived / read). Pinned bool `json:"pinned,omitempty"` @@ -442,7 +444,8 @@ type SessionMeta struct { // sidecar file they never touch is immune; losing it only degrades the // sidebar to session-derived recency, and the next turn re-stamps it. type ProjectMeta struct { - UpdatedAt string `json:"updated_at,omitempty"` // RFC3339 + UpdatedAt string `json:"updated_at,omitempty"` // RFC3339 + WorkspaceKind WorkspaceKind `json:"workspace_kind,omitempty"` } // sessionIndex is the on-disk structure of session.json. @@ -476,7 +479,7 @@ func isNewerTimestamp(candidate, current string) bool { // out-of-order write never moves the timestamp backwards). Callers must hold // indexMu, which serializes every read-modify-rename writer of BOTH the // session index and the projects file. -func touchProjectLocked(project, ts string) error { +func touchProjectLocked(project, ts string, kind WorkspaceKind) error { if project == "" || ts == "" { return nil } @@ -487,10 +490,20 @@ func touchProjectLocked(project, ts string) error { if projects == nil { projects = make(map[string]ProjectMeta) } - if !isNewerTimestamp(ts, projects[project].UpdatedAt) { + current := projects[project] + changed := false + if isNewerTimestamp(ts, current.UpdatedAt) { + current.UpdatedAt = ts + changed = true + } + if NormalizeWorkspaceKind(kind) == WorkspaceScratch && current.WorkspaceKind != WorkspaceScratch { + current.WorkspaceKind = WorkspaceScratch + changed = true + } + if !changed { return nil } - projects[project] = ProjectMeta{UpdatedAt: ts} + projects[project] = current return saveProjectsLocked(projects) } @@ -615,14 +628,15 @@ func openPrivateSessionAppend(path string) (*os.File, error) { // sessions with no conversation are never persisted. // Call Close() (or defer it) to finalize. type Recorder struct { - uuid string - project string - provider string - model string - agent string - startTime time.Time - file *os.File - mu sync.Mutex + uuid string + project string + workspaceKind WorkspaceKind + provider string + model string + agent string + startTime time.Time + file *os.File + mu sync.Mutex // Per-teammate fields (empty for leader recorder). customDir string // leader UUID for subagent path agentID string // teammate agent ID @@ -651,11 +665,12 @@ type Recorder struct { // best-effort and must not break normal operation. func NewRecorder(project, provider, model string) (*Recorder, error) { return &Recorder{ - uuid: uuid.New().String(), - project: project, - provider: provider, - model: model, - startTime: time.Now(), + uuid: uuid.New().String(), + project: project, + workspaceKind: WorkspaceProject, + provider: provider, + model: model, + startTime: time.Now(), }, nil } @@ -670,6 +685,21 @@ func (r *Recorder) UUID() string { // Project returns the workspace path this recorder is scoped to. func (r *Recorder) Project() string { return r.project } +// WorkspaceKind returns the conversation's persisted workspace classification. +func (r *Recorder) WorkspaceKind() WorkspaceKind { + r.mu.Lock() + defer r.mu.Unlock() + return NormalizeWorkspaceKind(r.workspaceKind) +} + +// SetWorkspaceKind classifies a recorder before its first durable entry. It is +// also safe on resumed recorders: the index is already authoritative there. +func (r *Recorder) SetWorkspaceKind(kind WorkspaceKind) { + r.mu.Lock() + r.workspaceKind = NormalizeWorkspaceKind(kind) + r.mu.Unlock() +} + // Provider returns the provider currently attributed to recorded usage. func (r *Recorder) Provider() string { r.mu.Lock() @@ -1436,13 +1466,14 @@ func (r *Recorder) ensureFile() error { // Write the header entry (timestamp already known). startEntry := Entry{ - Type: EntrySessionStart, - UUID: r.uuid, - Project: r.project, - Provider: r.provider, - Model: r.model, - Agent: r.agent, - Timestamp: r.startTime.Format(time.RFC3339), + Type: EntrySessionStart, + UUID: r.uuid, + Project: r.project, + WorkspaceKind: NormalizeWorkspaceKind(r.workspaceKind), + Provider: r.provider, + Model: r.model, + Agent: r.agent, + Timestamp: r.startTime.Format(time.RFC3339), } data, err := json.Marshal(startEntry) if err != nil { @@ -1455,12 +1486,13 @@ func (r *Recorder) ensureFile() error { // Update the shared index (non-fatal, skip for teammate recorders). if r.agentID == "" { _ = addToIndex(r.project, SessionMeta{ - UUID: r.uuid, - Project: r.project, - Provider: r.provider, - Model: r.model, - Agent: r.agent, - StartTime: r.startTime.Format(time.RFC3339), + UUID: r.uuid, + Project: r.project, + WorkspaceKind: NormalizeWorkspaceKind(r.workspaceKind), + Provider: r.provider, + Model: r.model, + Agent: r.agent, + StartTime: r.startTime.Format(time.RFC3339), }) } return nil @@ -1629,7 +1661,7 @@ func addToIndex(project string, meta SessionMeta) error { // the sidebar's project ordering reflects it. Best-effort AFTER the index // write succeeded — a projects-file hiccup must not fail session creation // (the sidebar falls back to session-derived recency). - _ = touchProjectLocked(project, meta.StartTime) + _ = touchProjectLocked(project, meta.StartTime, meta.WorkspaceKind) return nil } @@ -1818,7 +1850,7 @@ func UpdateSessionMeta(uuid string, mutate func(*SessionMeta)) (*SessionMeta, er // UpdatedAt untouched, so they never reorder projects either. // Best-effort, like addToIndex: the index write already succeeded. if metas[i].UpdatedAt != beforeUpdatedAt { - _ = touchProjectLocked(project, metas[i].UpdatedAt) + _ = touchProjectLocked(project, metas[i].UpdatedAt, metas[i].WorkspaceKind) } updated := metas[i] // The index keys sessions by project, so the stored meta may not diff --git a/internal/session/workspace_kind.go b/internal/session/workspace_kind.go new file mode 100644 index 0000000..0795c45 --- /dev/null +++ b/internal/session/workspace_kind.go @@ -0,0 +1,20 @@ +package session + +// WorkspaceKind describes how a conversation's execution directory relates to +// the user's project list. The zero value is deliberately treated as project +// so legacy session indexes remain project-bound after upgrade. +type WorkspaceKind string + +const ( + WorkspaceProject WorkspaceKind = "project" + WorkspaceScratch WorkspaceKind = "scratch" +) + +// NormalizeWorkspaceKind maps legacy/unknown values to the safe project +// default. Only JCode-created workspaces may be marked scratch. +func NormalizeWorkspaceKind(kind WorkspaceKind) WorkspaceKind { + if kind == WorkspaceScratch { + return WorkspaceScratch + } + return WorkspaceProject +} diff --git a/internal/session/workspace_kind_test.go b/internal/session/workspace_kind_test.go new file mode 100644 index 0000000..87857cd --- /dev/null +++ b/internal/session/workspace_kind_test.go @@ -0,0 +1,47 @@ +package session + +import "testing" + +func TestRecorderPersistsScratchWorkspaceKind(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + project := t.TempDir() + recorder, err := NewRecorder(project, "provider", "model") + if err != nil { + t.Fatal(err) + } + recorder.SetWorkspaceKind(WorkspaceScratch) + recorder.RecordUser("scratch task") + id := recorder.UUID() + recorder.Close() + + metas, err := ListSessions(project) + if err != nil { + t.Fatal(err) + } + if len(metas) != 1 || metas[0].WorkspaceKind != WorkspaceScratch { + t.Fatalf("scratch session metadata not persisted: %+v", metas) + } + entries, err := LoadSession(id) + if err != nil { + t.Fatal(err) + } + if len(entries) == 0 || entries[0].WorkspaceKind != WorkspaceScratch { + t.Fatalf("scratch session header not persisted: %+v", entries) + } + projects, err := ListProjectMeta() + if err != nil { + t.Fatal(err) + } + if projects[project].WorkspaceKind != WorkspaceScratch { + t.Fatalf("scratch project metadata not persisted: %+v", projects[project]) + } +} + +func TestNormalizeWorkspaceKindDefaultsLegacyToProject(t *testing.T) { + if got := NormalizeWorkspaceKind(""); got != WorkspaceProject { + t.Fatalf("legacy empty kind = %q, want project", got) + } + if got := NormalizeWorkspaceKind("unknown"); got != WorkspaceProject { + t.Fatalf("unknown kind = %q, want project", got) + } +} diff --git a/internal/web/activation.go b/internal/web/activation.go index b700218..299aaed 100644 --- a/internal/web/activation.go +++ b/internal/web/activation.go @@ -9,15 +9,18 @@ import ( "net" "net/http" "net/url" + "os" pathpkg "path" "path/filepath" "strings" + "time" "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/mode" "github.com/cnjack/jcode/internal/remote" "github.com/cnjack/jcode/internal/session" "github.com/cnjack/jcode/internal/tools" + managedworkspace "github.com/cnjack/jcode/internal/workspace" ) type conversationKind string @@ -94,19 +97,20 @@ func parseConversationTarget(project string) (conversationTarget, error) { } type activationResult struct { - Status string `json:"status"` - SessionID string `json:"session_id"` - Kind conversationKind `json:"kind"` - Pwd string `json:"pwd"` - Project string `json:"project"` - WorkspaceKey string `json:"workspace_key"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - Agent string `json:"agent,omitempty"` - Mode string `json:"mode"` - Running bool `json:"running"` - Activated bool `json:"activated"` - Focused bool `json:"focused"` + Status string `json:"status"` + SessionID string `json:"session_id"` + Kind conversationKind `json:"kind"` + Pwd string `json:"pwd"` + Project string `json:"project"` + WorkspaceKey string `json:"workspace_key"` + WorkspaceKind session.WorkspaceKind `json:"workspace_kind"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Agent string `json:"agent,omitempty"` + Mode string `json:"mode"` + Running bool `json:"running"` + Activated bool `json:"activated"` + Focused bool `json:"focused"` } func activationSnapshot(eng *Engine, kind conversationKind, activated bool) activationResult { @@ -115,7 +119,8 @@ func activationSnapshot(eng *Engine, kind conversationKind, activated bool) acti return activationResult{ Status: "ready", SessionID: eng.taskID, Kind: kind, Pwd: eng.pwd, Project: project, WorkspaceKey: project, - Provider: provider, Model: modelName, Agent: eng.curAgentRole(), Mode: modeName, + WorkspaceKind: session.NormalizeWorkspaceKind(eng.workspaceKind), + Provider: provider, Model: modelName, Agent: eng.curAgentRole(), Mode: modeName, Running: eng.running.Load(), Activated: activated, } } @@ -196,15 +201,24 @@ func writeConversationActivationError(w http.ResponseWriter, err error) { func (s *Server) ensureConversation( ctx context.Context, sessionID, projectPath, source string, +) (activationResult, error) { + return s.ensureConversationKind(ctx, sessionID, projectPath, source, "") +} + +func (s *Server) ensureConversationKind( + ctx context.Context, + sessionID, projectPath, source string, + requestedKind session.WorkspaceKind, ) (activationResult, error) { s.taskCreateMu.Lock() defer s.taskCreateMu.Unlock() - return s.ensureConversationLocked(ctx, sessionID, projectPath, source) + return s.ensureConversationLocked(ctx, sessionID, projectPath, source, requestedKind) } func (s *Server) ensureConversationLocked( ctx context.Context, sessionID, projectPath, source string, + requestedKind session.WorkspaceKind, ) (activationResult, error) { var meta *session.SessionMeta @@ -222,7 +236,38 @@ func (s *Server) ensureConversationLocked( } } + workspaceKind := requestedKind + switch { + case meta != nil: + workspaceKind = session.NormalizeWorkspaceKind(meta.WorkspaceKind) + case workspaceKind == "": + workspaceKind = session.WorkspaceProject + if sessionID == "" && projectPath == "" { + if active := s.activeEngine(); active != nil && active.workspaceKind == session.WorkspaceScratch { + workspaceKind = session.WorkspaceScratch + } + } + default: + workspaceKind = session.NormalizeWorkspaceKind(workspaceKind) + } project := projectPath + createdScratch := "" + if meta == nil && sessionID == "" && workspaceKind == session.WorkspaceScratch { + if projectPath != "" { + return activationResult{}, fmt.Errorf("%w: scratch workspace path is managed by JCode", errInvalidConversationTarget) + } + var createErr error + project, createErr = managedworkspace.CreateScratch(time.Now()) + if createErr != nil { + return activationResult{}, createErr + } + createdScratch = project + } + cleanupScratch := func() { + if createdScratch != "" { + _ = os.Remove(createdScratch) // only removes a failed attempt that stayed empty + } + } buildMode := s.activeMode() var restoredState *session.SessionState if meta != nil { @@ -256,9 +301,24 @@ func (s *Server) ensureConversationLocked( if project == "" { project = engineProject(old) } + // The managed scratch root is reserved. A new project-classified session may + // not bind one of those paths explicitly or by inheriting the active scratch + // engine; callers must request scratch so a fresh directory is allocated. + if meta == nil && workspaceKind == session.WorkspaceProject && project != "" { + if err := managedworkspace.ValidateScratchPath(project); err == nil { + return activationResult{}, fmt.Errorf("%w: managed scratch workspace cannot be opened as a project", errInvalidConversationTarget) + } + } + if workspaceKind == session.WorkspaceScratch { + if err := managedworkspace.ValidateScratchPath(project); err != nil { + cleanupScratch() + return activationResult{}, fmt.Errorf("%w: %v", errInvalidConversationTarget, err) + } + } target, err := parseConversationTarget(project) if err != nil { + cleanupScratch() return activationResult{}, fmt.Errorf("%w: %v", errInvalidConversationTarget, err) } if meta == nil && source != "" && target.kind != conversationLocal { @@ -281,8 +341,9 @@ func (s *Server) ensureConversationLocked( } } - eng, err := s.assembleConversationEngine(ctx, sessionID, target, buildMode) + eng, err := s.assembleConversationEngine(ctx, sessionID, target, buildMode, workspaceKind) if err != nil { + cleanupScratch() return activationResult{}, err } if restoredState != nil { @@ -290,6 +351,7 @@ func (s *Server) ensureConversationLocked( } if err := s.publishEngineCandidate(eng, old); err != nil { eng.teardown() + cleanupScratch() return activationResult{}, fmt.Errorf("publish conversation %s: %w", eng.taskID, err) } if sessionID == "" { @@ -303,12 +365,17 @@ func (s *Server) assembleConversationEngine( sessionID string, target conversationTarget, modeName string, + workspaceKind session.WorkspaceKind, ) (*Engine, error) { if target.kind == conversationLocal { - if s.newEngine == nil { + factory := s.newEngine + if session.NormalizeWorkspaceKind(workspaceKind) == session.WorkspaceScratch { + factory = s.newScratchEngine + } + if factory == nil { return nil, fmt.Errorf("activate local conversation: task creation is not supported") } - eng, err := s.assembleLocalEngine(sessionID, target.pwd, modeName, s.newEngine) + eng, err := s.assembleLocalEngine(sessionID, target.pwd, modeName, factory) if err != nil { return nil, fmt.Errorf("activate local conversation: %w", err) } diff --git a/internal/web/chat.go b/internal/web/chat.go index 52ab40e..f618b11 100644 --- a/internal/web/chat.go +++ b/internal/web/chat.go @@ -109,7 +109,12 @@ func (s *Server) engineForChatContext(ctx context.Context, taskID, modeStr strin return eng, nil } } - project := engineProject(s.activeEngine()) + active := s.activeEngine() + project := engineProject(active) + workspaceKind := session.WorkspaceProject + if active != nil { + workspaceKind = session.NormalizeWorkspaceKind(active.workspaceKind) + } if project == "" { // Setup-focused tests and embedders may have a bootstrap engine without a // workspace yet. This branch is only for a genuinely new, non-indexed task; @@ -131,7 +136,7 @@ func (s *Server) engineForChatContext(ctx context.Context, taskID, modeStr strin if err != nil { return nil, fmt.Errorf("resolve active conversation target: %w", err) } - eng, err := s.assembleConversationEngine(ctx, taskID, target, modeStr) + eng, err := s.assembleConversationEngine(ctx, taskID, target, modeStr, workspaceKind) if err != nil { return nil, err } @@ -267,6 +272,7 @@ func (s *Server) submitMessage(eng *Engine, message, mode, source, sessionID str eng.running.Store(false) return "", fmt.Errorf("create session recorder: returned nil recorder") } + rec.SetWorkspaceKind(eng.workspaceKind) rec.SetAgent(eng.agentRole) if sessionID != "" { rec.SetUUID(sessionID) @@ -294,6 +300,7 @@ func (s *Server) submitMessage(eng *Engine, message, mode, source, sessionID str eng.running.Store(false) return "", fmt.Errorf("create recorder for session %s: returned nil recorder", sessionID) } + rec.SetWorkspaceKind(eng.workspaceKind) rec.SetAgent(eng.agentRole) rec.SetUUID(sessionID) if eng.recorderInit != nil { diff --git a/internal/web/engine.go b/internal/web/engine.go index b2e765f..44257f6 100644 --- a/internal/web/engine.go +++ b/internal/web/engine.go @@ -59,6 +59,9 @@ type Engine struct { // it is immutable for the task's lifetime; "switching project" creates a new // Engine rather than mutating this one's env in place. pwd string + // workspaceKind distinguishes a user-selected project from a JCode-managed + // no-project workspace. It is immutable for the task's lifetime. + workspaceKind session.WorkspaceKind // --- run state (guarded today by Server.mu; gains its own lock in a later // increment once Server.mu's single-run role is gone) --- @@ -118,6 +121,7 @@ type Engine struct { type EngineConfig struct { TaskID string Pwd string + WorkspaceKind session.WorkspaceKind Mode string ProviderName string ModelName string @@ -165,6 +169,7 @@ func newEngine(c *EngineConfig) *Engine { e := &Engine{ taskID: taskID, pwd: c.Pwd, + workspaceKind: session.NormalizeWorkspaceKind(c.WorkspaceKind), mode: c.Mode, providerName: c.ProviderName, modelName: c.ModelName, diff --git a/internal/web/project.go b/internal/web/project.go index d0d4908..064bca8 100644 --- a/internal/web/project.go +++ b/internal/web/project.go @@ -13,6 +13,7 @@ import ( "github.com/cnjack/jcode/internal/config" "github.com/cnjack/jcode/internal/handler" "github.com/cnjack/jcode/internal/mode" + "github.com/cnjack/jcode/internal/session" ) func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { @@ -164,13 +165,15 @@ func (s *Server) handleSwitchProject(w http.ResponseWriter, r *http.Request) { s.wsBroker.Broadcast(WSEvent{ Type: "project_switched", Data: map[string]string{ - "pwd": req.Path, + "pwd": req.Path, + "workspace_kind": string(session.WorkspaceProject), }, }) writeJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "pwd": req.Path, + "status": "ok", + "pwd": req.Path, + "workspace_kind": session.WorkspaceProject, }) } diff --git a/internal/web/scratch_workspace_test.go b/internal/web/scratch_workspace_test.go new file mode 100644 index 0000000..fc694f3 --- /dev/null +++ b/internal/web/scratch_workspace_test.go @@ -0,0 +1,127 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/cnjack/jcode/internal/session" + managedworkspace "github.com/cnjack/jcode/internal/workspace" +) + +func TestNewScratchSessionAllocatesManagedWorkspace(t *testing.T) { + s := stubFactoryServer(t) + baseFactory := s.newEngine + s.newScratchEngine = func(taskID, pwd, modeName string) (*EngineConfig, error) { + cfg, err := baseFactory(taskID, pwd, modeName) + if err != nil { + return nil, err + } + cfg.WorkspaceKind = session.WorkspaceScratch + if cfg.Recorder != nil { + cfg.Recorder.SetWorkspaceKind(session.WorkspaceScratch) + } + return cfg, nil + } + + create := func(body string) activationResult { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, "/api/sessions", strings.NewReader(body), + ) + s.handleNewSession(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("create scratch: code=%d body=%q", rec.Code, rec.Body.String()) + } + var result activationResult + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result + } + + first := create(`{"workspace_kind":"scratch"}`) + // An older client sends an empty body for New Task. The active scratch + // classification still requires a fresh managed directory. + second := create("") + if first.WorkspaceKind != session.WorkspaceScratch || second.WorkspaceKind != session.WorkspaceScratch { + t.Fatalf("unexpected workspace kinds: first=%q second=%q", first.WorkspaceKind, second.WorkspaceKind) + } + if first.Pwd == second.Pwd { + t.Fatalf("scratch sessions reused workspace %q", first.Pwd) + } + for _, path := range []string{first.Pwd, second.Pwd} { + if err := managedworkspace.ValidateScratchPath(path); err != nil { + t.Fatalf("invalid managed workspace %q: %v", path, err) + } + if info, err := os.Stat(path); err != nil || !info.IsDir() { + t.Fatalf("scratch workspace missing: path=%q info=%v err=%v", path, info, err) + } + } + if got := s.activeEngine().workspaceKind; got != session.WorkspaceScratch { + t.Fatalf("active engine kind=%q, want scratch", got) + } + + // The activation endpoint is also used by Cloud/mobile new-chat commands. + // With no explicit target it must inherit scratch semantics and allocate a + // third directory, not build a project engine inside the active scratch path. + activateRec := httptest.NewRecorder() + activateReq := httptest.NewRequest(http.MethodPost, "/api/sessions/activate", strings.NewReader(`{}`)) + s.handleActivateSession(activateRec, activateReq) + if activateRec.Code != http.StatusOK { + t.Fatalf("activate scratch: code=%d body=%q", activateRec.Code, activateRec.Body.String()) + } + var activated activationResult + if err := json.Unmarshal(activateRec.Body.Bytes(), &activated); err != nil { + t.Fatal(err) + } + if activated.WorkspaceKind != session.WorkspaceScratch || activated.Pwd == second.Pwd { + t.Fatalf("activation did not allocate fresh scratch workspace: %+v", activated) + } +} + +func TestNewSessionRejectsUnknownWorkspaceKind(t *testing.T) { + s := stubFactoryServer(t) + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, "/api/sessions", strings.NewReader(`{"workspace_kind":"temporary"}`), + ) + s.handleNewSession(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) + } +} + +func TestNewProjectSessionRejectsActiveManagedScratchPath(t *testing.T) { + s := stubFactoryServer(t) + baseFactory := s.newEngine + s.newScratchEngine = func(taskID, pwd, modeName string) (*EngineConfig, error) { + cfg, err := baseFactory(taskID, pwd, modeName) + if err != nil { + return nil, err + } + cfg.WorkspaceKind = session.WorkspaceScratch + cfg.Recorder.SetWorkspaceKind(session.WorkspaceScratch) + return cfg, nil + } + + createScratch := httptest.NewRecorder() + s.handleNewSession(createScratch, httptest.NewRequest( + http.MethodPost, "/api/sessions", strings.NewReader(`{"workspace_kind":"scratch"}`), + )) + if createScratch.Code != http.StatusOK { + t.Fatalf("create scratch: code=%d body=%q", createScratch.Code, createScratch.Body.String()) + } + + rec := httptest.NewRecorder() + s.handleNewSession(rec, httptest.NewRequest( + http.MethodPost, "/api/sessions", strings.NewReader(`{"workspace_kind":"project"}`), + )) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 5778518..7abf237 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -92,6 +92,9 @@ type Server struct { // mutating any other task's. taskID is non-empty when resuming an existing // session. nil in setup mode. newEngine func(taskID, pwd, mode string) (*EngineConfig, error) + // newScratchEngine builds local tasks without project overlays, project + // skills/workflows, or project-memory writers. + newScratchEngine func(taskID, pwd, mode string) (*EngineConfig, error) // newRemoteEngine is newEngine's remote sibling: it builds a task engine bound // to a remote executor (SSH or Docker) instead of a local pwd. @@ -270,12 +273,14 @@ type ServerConfig struct { Host string OpenBrowser bool Pwd string + WorkspaceKind session.WorkspaceKind Version string Agent *adk.ChatModelAgent CreateAgent func(providerName, modelName string) (*adk.ChatModelAgent, error) RebuildForMode func(planMode bool) (*adk.ChatModelAgent, error) RebuildForRole func(roleName, providerName, modelName string) (*AgentRoleBuild, error) NewEngine func(taskID, pwd, mode string) (*EngineConfig, error) // factory for new concurrent task engines (local) + NewScratchEngine func(taskID, pwd, mode string) (*EngineConfig, error) // local JCode-managed no-project task factory NewRemoteEngine func(taskID string, executor tools.RemoteExecutor, remotePwd, mode string) (*EngineConfig, error) // remote sibling of NewEngine (SSH or Docker) NewAutomationEngine func(taskID, pwd, mode string) (*EngineConfig, error) // headless sibling of NewEngine for automation runs (drops interactive tools) InitialMode string // unified startup mode string ("approval"/"plan"/"full_access") @@ -327,6 +332,7 @@ func NewServer(cfg *ServerConfig) *Server { // The bootstrap Engine carries the per-task run state of the initial session. boot := &Engine{ pwd: cfg.Pwd, + workspaceKind: session.NormalizeWorkspaceKind(cfg.WorkspaceKind), handler: h, agent: cfg.Agent, todoStore: cfg.TodoStore, @@ -373,6 +379,7 @@ func NewServer(cfg *ServerConfig) *Server { version: cfg.Version, wsBroker: NewWSBroker(), newEngine: cfg.NewEngine, + newScratchEngine: cfg.NewScratchEngine, newRemoteEngine: cfg.NewRemoteEngine, newAutomationEngine: cfg.NewAutomationEngine, remoteConns: newRemoteConnRegistry(), @@ -682,9 +689,11 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { if s.needsSetup || eng == nil { pwd := "" project := "" + workspaceKind := session.WorkspaceProject if eng != nil { pwd = eng.pwd project = engineProject(eng) + workspaceKind = session.NormalizeWorkspaceKind(eng.workspaceKind) } writeJSON(w, http.StatusOK, map[string]any{ "status": "needs_setup", @@ -692,6 +701,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { "pwd": pwd, "project": project, "workspace_key": project, + "workspace_kind": workspaceKind, "provider": "", "model": "", "agent": "", @@ -728,6 +738,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { "pwd": eng.pwd, "project": project, "workspace_key": project, + "workspace_kind": session.NormalizeWorkspaceKind(eng.workspaceKind), "provider": provider, "model": mdl, "agent": eng.curAgentRole(), @@ -749,14 +760,15 @@ func (s *Server) statusSnapshot(eng *Engine) map[string]any { provider, mdl, modeStr := eng.modelSnapshot() project := engineProject(eng) return map[string]any{ - "running": eng.running.Load(), - "pwd": eng.pwd, - "project": project, - "workspace_key": project, - "provider": provider, - "model": mdl, - "agent": eng.curAgentRole(), - "mode": modeStr, + "running": eng.running.Load(), + "pwd": eng.pwd, + "project": project, + "workspace_key": project, + "workspace_kind": session.NormalizeWorkspaceKind(eng.workspaceKind), + "provider": provider, + "model": mdl, + "agent": eng.curAgentRole(), + "mode": modeStr, // Live token snapshot so a client reconnecting between turns can render // the context bar + cache hit rate without waiting for the next // token_update WS event. total_tokens = current context occupancy. diff --git a/internal/web/sessions.go b/internal/web/sessions.go index d9c067a..bf87cd3 100644 --- a/internal/web/sessions.go +++ b/internal/web/sessions.go @@ -19,27 +19,29 @@ import ( // field drifting (start_time vs created_at) that would blank created_at and // scramble the recency sort. type taskItem struct { - UUID string `json:"uuid"` - Project string `json:"project"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at,omitempty"` - Provider string `json:"provider"` - Model string `json:"model"` - Agent string `json:"agent,omitempty"` - Title string `json:"title,omitempty"` - Pinned bool `json:"pinned"` - Archived bool `json:"archived"` - Unread bool `json:"unread"` - Status string `json:"status,omitempty"` - Running bool `json:"running"` - ArtifactCount int `json:"artifact_count,omitempty"` - ArtifactUnseen bool `json:"artifact_unseen,omitempty"` + UUID string `json:"uuid"` + Project string `json:"project"` + WorkspaceKind session.WorkspaceKind `json:"workspace_kind"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at,omitempty"` + Provider string `json:"provider"` + Model string `json:"model"` + Agent string `json:"agent,omitempty"` + Title string `json:"title,omitempty"` + Pinned bool `json:"pinned"` + Archived bool `json:"archived"` + Unread bool `json:"unread"` + Status string `json:"status,omitempty"` + Running bool `json:"running"` + ArtifactCount int `json:"artifact_count,omitempty"` + ArtifactUnseen bool `json:"artifact_unseen,omitempty"` } func newTaskItem(m *session.SessionMeta, project string, running bool) taskItem { return taskItem{ UUID: m.UUID, Project: project, + WorkspaceKind: session.NormalizeWorkspaceKind(m.WorkspaceKind), CreatedAt: m.StartTime, UpdatedAt: m.UpdatedAt, Provider: m.Provider, @@ -106,8 +108,9 @@ func (s *Server) handleListAllTasks(w http.ResponseWriter, r *http.Request) { // from the surviving sessions, so deleting a conversation never reorders the // project list. type projectItem struct { - Path string `json:"path"` - UpdatedAt string `json:"updated_at,omitempty"` + Path string `json:"path"` + UpdatedAt string `json:"updated_at,omitempty"` + WorkspaceKind session.WorkspaceKind `json:"workspace_kind"` } // handleListProjects returns every project that has persisted metadata (last @@ -121,7 +124,10 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { } items := make([]projectItem, 0, len(meta)) for path, pm := range meta { - items = append(items, projectItem{Path: path, UpdatedAt: pm.UpdatedAt}) + items = append(items, projectItem{ + Path: path, UpdatedAt: pm.UpdatedAt, + WorkspaceKind: session.NormalizeWorkspaceKind(pm.WorkspaceKind), + }) } writeJSON(w, http.StatusOK, items) } @@ -377,8 +383,9 @@ func (s *Server) writeResumeReply(w http.ResponseWriter, eng *Engine, entries [] func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { var req struct { - SessionID string `json:"session_id,omitempty"` - Pwd string `json:"pwd,omitempty"` + SessionID string `json:"session_id,omitempty"` + Pwd string `json:"pwd,omitempty"` + WorkspaceKind session.WorkspaceKind `json:"workspace_kind,omitempty"` // Source is the optional channel label ("console"/"mobile") the cloud // relay passes through when the session is created from the cloud — // such sessions are always stamped as cloud-synced (M19). @@ -390,12 +397,18 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } + if req.WorkspaceKind != "" && req.WorkspaceKind != session.WorkspaceProject && req.WorkspaceKind != session.WorkspaceScratch { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid workspace_kind"}) + return + } // Build or recover the task without changing the foreground until every // remote connection and hydration step has succeeded. This is the same cold // activation path used by Cloud commands, so a persisted SSH/Docker UUID can // never silently fall back to a local engine. - result, err := s.ensureConversation(r.Context(), req.SessionID, req.Pwd, req.Source) + result, err := s.ensureConversationKind( + r.Context(), req.SessionID, req.Pwd, req.Source, req.WorkspaceKind, + ) if err != nil { writeConversationActivationError(w, err) return diff --git a/internal/workspace/scratch.go b/internal/workspace/scratch.go new file mode 100644 index 0000000..3f9b37e --- /dev/null +++ b/internal/workspace/scratch.go @@ -0,0 +1,107 @@ +// Package workspace owns JCode-managed local working directories. +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/cnjack/jcode/internal/config" +) + +const privateWorkspaceDirMode os.FileMode = 0o700 + +// ScratchRoot returns the directory that contains JCode-managed, no-project +// workspaces. It follows config.ConfigDir so isolated HOME test environments +// never touch a user's real ~/.jcode directory. +func ScratchRoot() string { + return filepath.Join(config.ConfigDir(), "workspace") +} + +// CreateScratch creates one private workspace named YYYY-MM-DD-NNN. The +// sequence is scoped to the local calendar day and allocated with an exclusive +// mkdir, so concurrent creators can race safely without a shared counter file. +func CreateScratch(now time.Time) (string, error) { + root := ScratchRoot() + if err := os.MkdirAll(root, privateWorkspaceDirMode); err != nil { + return "", fmt.Errorf("create scratch workspace root: %w", err) + } + if err := os.Chmod(root, privateWorkspaceDirMode); err != nil { + return "", fmt.Errorf("secure scratch workspace root: %w", err) + } + + prefix := now.Format("2006-01-02") + "-" + entries, err := os.ReadDir(root) + if err != nil { + return "", fmt.Errorf("list scratch workspaces: %w", err) + } + maxSeq := 0 + for _, entry := range entries { + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) { + continue + } + seq, parseErr := strconv.Atoi(strings.TrimPrefix(entry.Name(), prefix)) + if parseErr == nil && seq > maxSeq { + maxSeq = seq + } + } + + for seq := maxSeq + 1; ; seq++ { + path := filepath.Join(root, fmt.Sprintf("%s%03d", prefix, seq)) + err = os.Mkdir(path, privateWorkspaceDirMode) + if err == nil { + return path, nil + } + if os.IsExist(err) { + continue + } + return "", fmt.Errorf("create scratch workspace: %w", err) + } +} + +// ValidateScratchPath verifies that path is an existing, real directory created +// directly beneath ScratchRoot with the managed date/sequence name. Persisted +// metadata marked scratch must pass this check before it can select the +// scratch-only engine factory. +func ValidateScratchPath(path string) error { + root, err := filepath.Abs(ScratchRoot()) + if err != nil { + return fmt.Errorf("resolve scratch workspace root: %w", err) + } + abs, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolve scratch workspace: %w", err) + } + if resolved, resolveErr := filepath.EvalSymlinks(root); resolveErr == nil { + root = resolved + } + if resolved, resolveErr := filepath.EvalSymlinks(abs); resolveErr == nil { + abs = resolved + } + rel, err := filepath.Rel(root, abs) + if err != nil || rel == "." || filepath.Dir(rel) != "." { + return fmt.Errorf("scratch workspace is outside the managed root") + } + name := filepath.Base(rel) + if len(name) < len("2006-01-02-1") { + return fmt.Errorf("scratch workspace has an invalid managed name") + } + if _, err := time.Parse("2006-01-02", name[:10]); err != nil || name[10] != '-' { + return fmt.Errorf("scratch workspace has an invalid managed date") + } + seq, err := strconv.Atoi(name[11:]) + if err != nil || seq < 1 { + return fmt.Errorf("scratch workspace has an invalid managed sequence") + } + info, err := os.Lstat(abs) + if err != nil { + return fmt.Errorf("stat scratch workspace: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("scratch workspace is not a real directory") + } + return nil +} diff --git a/internal/workspace/scratch_test.go b/internal/workspace/scratch_test.go new file mode 100644 index 0000000..e6fdcd0 --- /dev/null +++ b/internal/workspace/scratch_test.go @@ -0,0 +1,74 @@ +package workspace + +import ( + "path/filepath" + "sync" + "testing" + "time" +) + +func TestCreateScratchUsesDailyMonotonicSequence(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + now := time.Date(2026, time.August, 19, 23, 59, 0, 0, time.Local) + + first, err := CreateScratch(now) + if err != nil { + t.Fatal(err) + } + second, err := CreateScratch(now) + if err != nil { + t.Fatal(err) + } + nextDay, err := CreateScratch(now.Add(2 * time.Minute)) + if err != nil { + t.Fatal(err) + } + + if got, want := filepath.Base(first), "2026-08-19-001"; got != want { + t.Fatalf("first workspace = %q, want %q", got, want) + } + if got, want := filepath.Base(second), "2026-08-19-002"; got != want { + t.Fatalf("second workspace = %q, want %q", got, want) + } + if got, want := filepath.Base(nextDay), "2026-08-20-001"; got != want { + t.Fatalf("next-day workspace = %q, want %q", got, want) + } +} + +func TestCreateScratchIsConcurrentSafe(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + now := time.Date(2026, time.August, 19, 12, 0, 0, 0, time.Local) + + const count = 16 + paths := make(chan string, count) + errs := make(chan error, count) + var wg sync.WaitGroup + for range count { + wg.Add(1) + go func() { + defer wg.Done() + path, err := CreateScratch(now) + if err != nil { + errs <- err + return + } + paths <- path + }() + } + wg.Wait() + close(paths) + close(errs) + for err := range errs { + t.Fatal(err) + } + seen := make(map[string]bool, count) + for path := range paths { + if seen[path] { + t.Fatalf("duplicate workspace allocated: %s", path) + } + seen[path] = true + } + if len(seen) != count { + t.Fatalf("allocated %d workspaces, want %d", len(seen), count) + } +} diff --git a/packages/jcode-ui/src/product/ChatInput.test.tsx b/packages/jcode-ui/src/product/ChatInput.test.tsx index 8f1b0c0..cdd7759 100644 --- a/packages/jcode-ui/src/product/ChatInput.test.tsx +++ b/packages/jcode-ui/src/product/ChatInput.test.tsx @@ -55,6 +55,7 @@ function makeHost(overrides: Partial = {}): ProductComposer goalArmed: false, sessionId: 's1', projectPath: '/tmp/project', + workspaceKind: 'project', tasks: [], selectModel: vi.fn(), selectMode: vi.fn(), @@ -68,6 +69,7 @@ function makeHost(overrides: Partial = {}): ProductComposer validateWorkspacePaths: vi.fn(async () => []), browseFolders: vi.fn(async () => ({ current: '/', folders: [] })), switchWorkspace: vi.fn(async () => {}), + startScratchWorkspace: vi.fn(async () => {}), fetchBranches: vi.fn(async () => ({ current: '', branches: [] })), checkoutBranch: vi.fn(async () => ({ branch: '' })), setGoal: vi.fn(async () => ({ objective: '', status: 'active' as const })), @@ -240,6 +242,57 @@ describe('workspace picker', () => { await waitFor(() => expect(browseFolders).toHaveBeenCalledWith('/tmp/project')) expect(screen.getByText('src')).toBeTruthy() }) + + it('creates a managed no-project workspace without listing scratch paths as projects', async () => { + const startScratchWorkspace = vi.fn(async () => {}) + renderComposer(makeHost({ + startScratchWorkspace, + tasks: [ + { uuid: 'project-task', project: '/tmp/project', workspace_kind: 'project', updated_at: '2026-08-19T10:00:00Z' }, + { uuid: 'scratch-task', project: '/tmp/.jcode/workspace/2026-08-19-001', workspace_kind: 'scratch', updated_at: '2026-08-19T11:00:00Z' }, + ], + })) + + fireEvent.click(screen.getByText('project')) + expect(screen.queryByText('2026-08-19-001')).toBeNull() + fireEvent.click(screen.getByText('Work without a project')) + await waitFor(() => expect(startScratchWorkspace).toHaveBeenCalledTimes(1)) + }) + + it('orders merged workspaces by timestamp instant across UTC offsets', () => { + const { container } = renderComposer(makeHost({ + projectPath: '', + tasks: [ + // 02:00 UTC — the older activity for this workspace. + { uuid: 'older-mixed', project: '/tmp/mixed', workspace_kind: 'project', updated_at: '2026-08-19T10:00:00+08:00' }, + // 05:00 UTC — newer despite its smaller local clock value. + { uuid: 'newer-mixed', project: '/tmp/mixed', workspace_kind: 'project', updated_at: '2026-08-19T05:00:00Z' }, + // 04:00 UTC — should follow the merged /tmp/mixed workspace. + { uuid: 'intermediate', project: '/tmp/intermediate', workspace_kind: 'project', updated_at: '2026-08-19T12:00:00+08:00' }, + ], + })) + + const pickerButton = container.querySelector('.ws-pill-action') + if (!pickerButton) throw new Error('workspace picker button not found') + fireEvent.click(pickerButton) + + const names = [...container.querySelectorAll('.ws-row-name')].map((node) => node.textContent) + expect(names).toEqual(['mixed', 'intermediate']) + }) + + it('shows the no-project state as selected without allocating again', () => { + const startScratchWorkspace = vi.fn(async () => {}) + renderComposer(makeHost({ + projectPath: '/tmp/.jcode/workspace/2026-08-19-001', + workspaceKind: 'scratch', + startScratchWorkspace, + })) + + fireEvent.click(screen.getByText('Work without a project')) + const labels = screen.getAllByText('Work without a project') + fireEvent.click(labels[labels.length - 1]) + expect(startScratchWorkspace).not.toHaveBeenCalled() + }) }) describe('model picker', () => { diff --git a/packages/jcode-ui/src/product/WorkspacePicker.tsx b/packages/jcode-ui/src/product/WorkspacePicker.tsx index 317d888..7bfe9f3 100644 --- a/packages/jcode-ui/src/product/WorkspacePicker.tsx +++ b/packages/jcode-ui/src/product/WorkspacePicker.tsx @@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ArrowLeftIcon, + ChatBubbleLeftRightIcon, CheckIcon, ChevronDownIcon, FolderIcon, @@ -27,6 +28,7 @@ interface WorkspaceNode { path: string name: string remote: boolean + updatedAt: string } interface BrowseFolder { @@ -34,9 +36,21 @@ interface BrowseFolder { path: string } +// Session timestamps can be RFC3339 values with either UTC or a numeric offset. +// Compare parsed instants so a later UTC time is not hidden behind a larger local +// clock value. Invalid and missing timestamps sort as the oldest activity. +function compareWorkspaceActivity(a: string, b: string): number { + const aTime = a ? Date.parse(a) : Number.NaN + const bTime = b ? Date.parse(b) : Number.NaN + if (Number.isNaN(aTime)) return Number.isNaN(bTime) ? 0 : -1 + if (Number.isNaN(bTime)) return 1 + return aTime - bTime +} + export function WorkspacePicker({ host, placement = 'top' }: { host: ProductComposerHost; placement?: 'top' | 'bottom' }) { const strings = useComposerStrings(host) const activePath = host.projectPath + const activeScratch = host.workspaceKind === 'scratch' const tasks = host.tasks const { isRunning } = useRuntimeState() const [open, setOpen] = useState(false) @@ -53,10 +67,23 @@ export function WorkspacePicker({ host, placement = 'top' }: { host: ProductComp const workspaces = useMemo(() => { const map = new Map() - if (activePath) map.set(activePath, { path: activePath, name: workspaceName(activePath), remote: isRemotePath(activePath) }) + if (activePath && !activeScratch) { + map.set(activePath, { path: activePath, name: workspaceName(activePath), remote: isRemotePath(activePath), updatedAt: '' }) + } for (const task of tasks) { - if (!task.project || map.has(task.project)) continue - map.set(task.project, { path: task.project, name: workspaceName(task.project), remote: isRemotePath(task.project) }) + if (!task.project || task.workspace_kind === 'scratch') continue + const existing = map.get(task.project) + if (existing) { + const updatedAt = task.updated_at || '' + if (compareWorkspaceActivity(updatedAt, existing.updatedAt) > 0) existing.updatedAt = updatedAt + continue + } + map.set(task.project, { + path: task.project, + name: workspaceName(task.project), + remote: isRemotePath(task.project), + updatedAt: task.updated_at || '', + }) } const q = query.trim().toLowerCase() return [...map.values()] @@ -65,16 +92,24 @@ export function WorkspacePicker({ host, placement = 'top' }: { host: ProductComp .sort((a, b) => { if (a.path === activePath) return -1 if (b.path === activePath) return 1 - return a.name.localeCompare(b.name) + const byActivity = compareWorkspaceActivity(b.updatedAt, a.updatedAt) + if (byActivity !== 0) return byActivity + const byName = a.name.localeCompare(b.name) + return byName !== 0 ? byName : a.path.localeCompare(b.path) }) - }, [activePath, tasks, missing, query]) + }, [activePath, activeScratch, tasks, missing, query]) - const activeName = activePath ? workspaceName(activePath) : strings.workspaceNone - const activeRemote = isRemotePath(activePath) + const activeName = activeScratch + ? strings.workspaceScratchAction + : activePath ? workspaceName(activePath) : strings.workspaceNone + const activeRemote = !activeScratch && isRemotePath(activePath) const validateKnownPaths = useCallback(async () => { - const localPaths = [...new Set(tasks.map((t) => t.project).filter((p) => p && !isRemotePath(p)))] - if (activePath && !isRemotePath(activePath)) localPaths.push(activePath) + const localPaths = [...new Set(tasks + .filter((task) => task.workspace_kind !== 'scratch') + .map((task) => task.project) + .filter((p) => p && !isRemotePath(p)))] + if (activePath && !activeScratch && !isRemotePath(activePath)) localPaths.push(activePath) if (localPaths.length === 0) return try { const missingPaths = await host.validateWorkspacePaths([...new Set(localPaths)]) @@ -82,7 +117,7 @@ export function WorkspacePicker({ host, placement = 'top' }: { host: ProductComp } catch { // The picker remains usable; failed validation should not hide paths. } - }, [activePath, tasks, host]) + }, [activePath, activeScratch, tasks, host]) useEffect(() => { void validateKnownPaths() @@ -169,6 +204,24 @@ export function WorkspacePicker({ host, placement = 'top' }: { host: ProductComp host.openRemoteConnect?.() } + async function startScratch() { + if (activeScratch) { + reset() + return + } + if (!host.startScratchWorkspace) return + setSwitching(true) + setError('') + try { + await host.startScratchWorkspace() + reset() + } catch (e) { + setError(e instanceof Error ? e.message : strings.workspaceOpenError) + } finally { + setSwitching(false) + } + } + function reset() { setOpen(false) setQuery('') @@ -200,7 +253,9 @@ export function WorkspacePicker({ host, placement = 'top' }: { host: ProductComp }} className="ws-pill ws-pill-action" > - {activeRemote + {activeScratch + ? + : activeRemote ? : } {activeName} @@ -292,6 +347,14 @@ export function WorkspacePicker({ host, placement = 'top' }: { host: ProductComp {strings.remoteConnect} } + {host.startScratchWorkspace && <> +
+ + }
)} @@ -484,7 +547,10 @@ const WS_CSS = ` transition: background 0.12s; } .ws-action:hover { background: var(--color-muted); } +.ws-action.active { background: var(--neutral-wash-soft, var(--color-muted)); } +.ws-action:disabled { opacity: 0.55; cursor: not-allowed; } .ws-action svg { color: var(--color-muted-foreground); flex-shrink: 0; } +.ws-action-separator { height: 1px; margin: 2px 0; background: var(--color-border); } .ws-browser-foot { display: flex; align-items: center; diff --git a/packages/jcode-ui/src/product/host.ts b/packages/jcode-ui/src/product/host.ts index fdc9c57..9dc0e22 100644 --- a/packages/jcode-ui/src/product/host.ts +++ b/packages/jcode-ui/src/product/host.ts @@ -69,6 +69,8 @@ export interface ProductComposerHost { // ── Workspace state ─────────────────────────────────────────────────────── projectPath: string + /** Missing means a legacy project-bound host. */ + workspaceKind?: 'project' | 'scratch' /** All known tasks; the workspace picker derives the workspace list from `project`. */ tasks: WorkspaceTaskRef[] @@ -109,6 +111,8 @@ export interface ProductComposerHost { * (the picker shows the error inline). */ switchWorkspace: (path: string) => Promise + /** Create and focus a fresh JCode-managed no-project workspace. */ + startScratchWorkspace?: () => Promise /** * Desktop-native folder picker. Absent ⇒ the picker only offers the in-app * folder browser. A rejection falls back to the in-app browser; null = user diff --git a/packages/jcode-ui/src/product/index.ts b/packages/jcode-ui/src/product/index.ts index b20a6d9..7081dd2 100644 --- a/packages/jcode-ui/src/product/index.ts +++ b/packages/jcode-ui/src/product/index.ts @@ -41,6 +41,7 @@ export type { SlashCommandInfo, TaskContextBreakdown, TaskStats, + WorkspaceKind, WorkspaceTaskRef, BrowseFolder, BrowseResult, diff --git a/packages/jcode-ui/src/product/strings.ts b/packages/jcode-ui/src/product/strings.ts index 3c4ea96..a5f5419 100644 --- a/packages/jcode-ui/src/product/strings.ts +++ b/packages/jcode-ui/src/product/strings.ts @@ -94,6 +94,7 @@ export interface ProductComposerStrings { workspaceOpenFolder: string workspaceOpenError: string workspacePathPlaceholder: string + workspaceScratchAction: string remoteConnect: string // ── branch picker ── @@ -221,6 +222,7 @@ export const defaultProductComposerStrings: ProductComposerStrings = { workspaceOpenFolder: 'Open folder', workspaceOpenError: 'Failed to open workspace', workspacePathPlaceholder: '/path/to/folder', + workspaceScratchAction: 'Work without a project', remoteConnect: 'Remote connect', branchesTitle: 'Branches', diff --git a/packages/jcode-ui/src/product/types.ts b/packages/jcode-ui/src/product/types.ts index 6d1ff05..79ccd2a 100644 --- a/packages/jcode-ui/src/product/types.ts +++ b/packages/jcode-ui/src/product/types.ts @@ -106,10 +106,14 @@ export interface TaskStats { // ─── Workspace ────────────────────────────────────────────────────────────── -/** Minimal task shape the workspace picker consumes (it only reads `project`). */ +export type WorkspaceKind = 'project' | 'scratch' + +/** Minimal task shape the workspace picker consumes. */ export interface WorkspaceTaskRef { uuid: string project: string + workspace_kind?: WorkspaceKind + updated_at?: string } export interface BrowseFolder { diff --git a/web/src/App.tsx b/web/src/App.tsx index de81411..da38d45 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -87,6 +87,7 @@ export default function App() { dispatch(modelActions.setServerVersion(h.version)) dispatch(modelActions.setImageSupport(!!h.image_support)) dispatch(sessionActions.setProjectPath(h.project || h.workspace_key || h.pwd)) + dispatch(sessionActions.setWorkspaceKind(h.workspace_kind)) const restoreSessionId = isTauri ? h.recent_session_id || h.session_id : h.session_id const restoreProject = isTauri && h.recent_session_id ? h.recent_project || h.project || h.workspace_key || h.pwd @@ -107,6 +108,7 @@ export default function App() { uuid: restoreSessionId, project: indexedTask?.project || restoreProject, title: indexedTask?.title || indexedSession?.title, + workspaceKind: indexedTask?.workspace_kind, })) } } diff --git a/web/src/app/composerHost.ts b/web/src/app/composerHost.ts index 221e4f2..5cc7989 100644 --- a/web/src/app/composerHost.ts +++ b/web/src/app/composerHost.ts @@ -19,6 +19,7 @@ import { modelActions, sessionActions, loadWorkspaceState, + startScratchChat, } from './store' import { api } from '../lib/api' import { iconForProvider } from '../lib/providerIcons' @@ -103,6 +104,7 @@ function buildStrings(t: (key: string, opts?: Record) => string workspaceOpenFolder: t('workspace.openFolder'), workspaceOpenError: t('workspace.openError'), workspacePathPlaceholder: t('projectSwitcher.pathPlaceholder'), + workspaceScratchAction: t('workspace.workWithoutProject'), remoteConnect: t('nav.remoteConnect'), branchesTitle: t('branches.title'), @@ -249,9 +251,11 @@ const actions = { async switchWorkspace(path: string) { const state = store.getState() as RootState if (path === state.session.projectPath) { - const resp = await api.newSession() + const resp = await api.newSession(undefined, undefined, state.session.workspaceKind) store.dispatch(chatActions.clearChat()) store.dispatch(sessionActions.setCurrentSession(resp.session_id)) + store.dispatch(sessionActions.setProjectPath(resp.project || resp.workspace_key || resp.pwd || path)) + store.dispatch(sessionActions.setWorkspaceKind(resp.workspace_kind)) await store.dispatch(loadWorkspaceState()) return } @@ -259,9 +263,14 @@ const actions = { store.dispatch(chatActions.clearChat()) store.dispatch(sessionActions.setCurrentSession('')) store.dispatch(sessionActions.setProjectPath(resp.pwd || path)) + store.dispatch(sessionActions.setWorkspaceKind(resp.workspace_kind)) await store.dispatch(loadWorkspaceState()) }, + async startScratchWorkspace() { + await store.dispatch(startScratchChat()).unwrap() + }, + openRemoteConnect, fetchBranches() { @@ -311,6 +320,7 @@ export function useProductComposerHost(): ProductComposerHost { const goalArmed = useAppSelector((s) => s.chat.goalArmed) const sessionId = useAppSelector((s) => s.session.currentSessionId) const projectPath = useAppSelector((s) => s.session.projectPath) + const workspaceKind = useAppSelector((s) => s.session.workspaceKind) const tasks = useAppSelector((s) => s.session.tasks) const strings = useMemo(() => buildStrings(t), [t]) @@ -332,6 +342,7 @@ export function useProductComposerHost(): ProductComposerHost { goalArmed, sessionId, projectPath, + workspaceKind, tasks, strings, resolveProviderIcon: iconForProvider, @@ -341,7 +352,7 @@ export function useProductComposerHost(): ProductComposerHost { [ providerName, modelName, mode, providers, favoriteModels, recentModels, imageSupport, effortOverrides, agents, agentName, slashCommands, hasMessages, goalArmed, - sessionId, projectPath, tasks, strings, + sessionId, projectPath, workspaceKind, tasks, strings, ], ) } diff --git a/web/src/app/conversationLoad.test.ts b/web/src/app/conversationLoad.test.ts index acbbc2f..391c5a7 100644 --- a/web/src/app/conversationLoad.test.ts +++ b/web/src/app/conversationLoad.test.ts @@ -11,6 +11,7 @@ import { openConversation, sessionActions, startNewChat, + startScratchChat, store, } from './store' import { createWSHandlers } from './wsBridge' @@ -22,6 +23,7 @@ beforeEach(async () => { store.dispatch(chatActions.dropSessionQueue('session-agent-done-commit')) store.dispatch(sessionActions.setCurrentSession('')) store.dispatch(sessionActions.setProjectPath('')) + store.dispatch(sessionActions.setWorkspaceKind('project')) }) afterEach(async () => { @@ -364,6 +366,26 @@ describe('conversation loading state', () => { expect(store.getState().chat.timeline).toEqual([]) }) + it('allocates a fresh scratch workspace for no-project new tasks', async () => { + const scratchPath = '/Users/test/.jcode/workspace/2026-08-19-001' + const create = vi.spyOn(api, 'newSession').mockResolvedValue({ + status: 'ok', + session_id: 'scratch-session', + pwd: scratchPath, + project: scratchPath, + workspace_kind: 'scratch', + }) + + await store.dispatch(startScratchChat()) + + expect(create).toHaveBeenCalledWith(undefined, undefined, 'scratch') + expect(store.getState().session).toMatchObject({ + currentSessionId: 'scratch-session', + projectPath: scratchPath, + workspaceKind: 'scratch', + }) + }) + it('guards a background history repair from overwriting a newer navigation', async () => { store.dispatch(sessionActions.setCurrentSession('session-old')) store.dispatch(chatActions.addMessage({ role: 'user', content: 'keep me' })) diff --git a/web/src/app/store.test.ts b/web/src/app/store.test.ts index 5267107..21362f1 100644 --- a/web/src/app/store.test.ts +++ b/web/src/app/store.test.ts @@ -5,11 +5,32 @@ */ import { describe, it, expect, afterEach } from 'vitest' -import { store, uiActions } from './store' +import { sessionActions, store, uiActions } from './store' afterEach(() => { store.dispatch(uiActions.setView('chat')) store.dispatch(uiActions.setSettingsTab('general')) + store.dispatch(sessionActions.setTasks([])) +}) + +describe('workspace activity classification', () => { + it('uses authoritative task metadata for a background scratch path', () => { + const scratchPath = '/tmp/.jcode/workspace/2026-08-19-009' + store.dispatch(sessionActions.setProjectPath('/work/current')) + store.dispatch(sessionActions.setWorkspaceKind('project')) + store.dispatch(sessionActions.setTasks([{ + uuid: 'scratch-background', + project: scratchPath, + workspace_kind: 'scratch', + created_at: '2026-08-19T10:00:00Z', + provider: 'openai', model: 'gpt-5', title: 'Background scratch', + pinned: false, archived: false, unread: false, + }])) + + store.dispatch(sessionActions.touchProjectTime({ path: scratchPath, ts: '2026-08-19T10:01:00Z' })) + + expect(store.getState().session.projectKinds[scratchPath]).toBe('scratch') + }) }) describe('ui routing (M18 settings view)', () => { diff --git a/web/src/app/store.ts b/web/src/app/store.ts index b1dc138..40d1680 100644 --- a/web/src/app/store.ts +++ b/web/src/app/store.ts @@ -28,7 +28,7 @@ import type { } from 'jcode-ui-core' import { api, isAPIError } from '../lib/api' import { extractToolDisplayInfo } from '../lib/toolInfo' -import { normalizeMode, type AgentMode, type CustomAgentInfo, type ProviderInfo, type SessionItem, type TaskItem, type ProjectInfo, type SlashCommandInfo, type SessionEntry, type ModelRef, type RemoteConnectRequest, type RemoteHostKeyErrorPayload, type RemoteHostKeyErrorCode, type RemoteConnectionStatusData, type ModelRetryStatusData, type SessionActivationResponse } from '../lib/types' +import { normalizeMode, type AgentMode, type CustomAgentInfo, type ProviderInfo, type SessionItem, type TaskItem, type ProjectInfo, type SlashCommandInfo, type SessionEntry, type ModelRef, type RemoteConnectRequest, type RemoteHostKeyErrorPayload, type RemoteHostKeyErrorCode, type RemoteConnectionStatusData, type ModelRetryStatusData, type SessionActivationResponse, type WorkspaceKind } from '../lib/types' import { parseRemoteLabel } from '../lib/remote' import { i18n, setLocale, SUPPORTED_LOCALES } from '../i18n' import { hydrateTheme } from '../lib/useTheme' @@ -961,8 +961,11 @@ interface SessionState { * end — never on delete — so the sidebar's project ordering is stable * across conversation deletions. */ projectTimes: Record + /** Workspace classification for projectTimes entries, keyed by real path. */ + projectKinds: Record currentSessionId: string projectPath: string + workspaceKind: WorkspaceKind wsConnected: boolean } @@ -970,8 +973,10 @@ const initialSession: SessionState = { sessions: [], tasks: [], projectTimes: {}, + projectKinds: {}, currentSessionId: '', projectPath: '', + workspaceKind: 'project', wsConnected: false, } @@ -1005,6 +1010,10 @@ const sessionSlice = createSlice({ setProjectPath(s, a: { payload: string }) { s.projectPath = a.payload }, + setWorkspaceKind(s, a: { payload: WorkspaceKind | undefined }) { + s.workspaceKind = a.payload === 'scratch' ? 'scratch' : 'project' + if (s.projectPath) s.projectKinds[s.projectPath] = s.workspaceKind + }, setWsConnected(s, a: { payload: boolean }) { s.wsConnected = a.payload }, @@ -1020,6 +1029,7 @@ const sessionSlice = createSlice({ for (const p of a.payload) { if (!p.path || !p.updated_at) continue if (isNewerTs(p.updated_at, s.projectTimes[p.path])) s.projectTimes[p.path] = p.updated_at + s.projectKinds[p.path] = p.workspace_kind === 'scratch' ? 'scratch' : 'project' } }, /** Live-bump one project's timestamp (a turn started/ended there). Mirrors @@ -1028,6 +1038,12 @@ const sessionSlice = createSlice({ const { path, ts } = a.payload if (!path || !ts) return if (isNewerTs(ts, s.projectTimes[path])) s.projectTimes[path] = ts + if (!s.projectKinds[path]) { + const taskKind = s.tasks.find((task) => task.project === path)?.workspace_kind + s.projectKinds[path] = taskKind === 'scratch' + ? 'scratch' + : path === s.projectPath ? s.workspaceKind : 'project' + } }, /** Insert or merge a task so the sidebar shows it immediately. */ upsertTask(s, a: { payload: TaskItem }) { @@ -1073,6 +1089,7 @@ export interface ConversationLoadTarget { uuid: string project: string title?: string + workspaceKind?: WorkspaceKind } export type ConversationLoadPhase = @@ -1649,6 +1666,7 @@ export function revealSessionInSidebar( project?: string provider?: string model?: string + workspaceKind?: WorkspaceKind }, ) { if (!opts.uuid) return @@ -1661,6 +1679,7 @@ export function revealSessionInSidebar( dispatch(sessionActions.upsertTask({ uuid: opts.uuid, project: existing?.project || project, + workspace_kind: opts.workspaceKind || existing?.workspace_kind || state.session.workspaceKind, created_at: existing?.created_at || now, updated_at: now, provider: opts.provider || existing?.provider || state.model.providerName || '', @@ -1861,6 +1880,7 @@ export const loadStatus = createAsyncThunk('app/loadStatus', async (_, { dispatc const status = await api.status() dispatch(chatActions.setRunning(!!status.running)) dispatch(sessionActions.setProjectPath(status.project || status.workspace_key || status.pwd)) + dispatch(sessionActions.setWorkspaceKind(status.workspace_kind)) dispatch(modelActions.setProvider(status.provider)) dispatch(modelActions.setModel(status.model)) dispatch(modelActions.setAgent(status.agent || '')) @@ -2194,6 +2214,7 @@ async function connectAndBindConversation( pwd: bind.pwd, project: bind.project || bind.label || target.project, workspace_key: bind.workspace_key || bind.project || bind.label || target.project, + workspace_kind: bind.workspace_kind || 'project', provider: bind.provider, model: bind.model, agent: bind.agent, @@ -2224,6 +2245,7 @@ async function commitConversation( const resumedId = response.session_id || target.uuid dispatch(sessionActions.setCurrentSession(resumedId)) dispatch(sessionActions.setProjectPath(response.project || response.workspace_key || target.project || response.pwd)) + dispatch(sessionActions.setWorkspaceKind(response.workspace_kind || target.workspaceKind)) dispatch(remoteConnectionActions.clear({ taskId: resumedId })) dispatch(chatActions.setRunning(!!response.running)) dispatch(modelActions.setProvider(response.provider || '')) @@ -2362,6 +2384,7 @@ export const openConversation = createAsyncThunk( uuid: input.uuid, project: input.project || indexedTask?.project || state.session.projectPath, title: input.title || indexedTask?.title || indexedSession?.title, + workspaceKind: input.workspaceKind || indexedTask?.workspace_kind || state.session.workspaceKind, } dispatch(uiActions.setView('chat')) dispatch(conversationLoadActions.begin({ requestId, target })) @@ -2394,6 +2417,7 @@ export const retryRemoteConnection = createAsyncThunk( uuid: input.taskId, project: task?.project || (initial.session.currentSessionId === input.taskId ? initial.session.projectPath : ''), title: task?.title || session?.title, + workspaceKind: task?.workspace_kind || initial.session.workspaceKind, } if (notice.status === 'action_required') { @@ -2429,6 +2453,7 @@ export const retryRemoteConnection = createAsyncThunk( return } dispatch(sessionActions.setProjectPath(response.project || response.workspace_key || target.project || response.pwd)) + dispatch(sessionActions.setWorkspaceKind(response.workspace_kind || target.workspaceKind)) dispatch(remoteConnectionActions.statusReceived({ task_id: input.taskId, kind: response.kind === 'docker' ? 'docker' : 'ssh', @@ -2595,21 +2620,42 @@ export const loadSession = createAsyncThunk( * ⌘N / ⇧⌘O keyboard shortcuts. The empty session stays out of the sidebar until * the first user message (backend only indexes then). */ -export const startNewChat = createAsyncThunk('session/startNew', async (_, { dispatch }) => { +async function provisionNewChat( + dispatch: AppDispatch, + getState: () => RootState, + overrideKind?: WorkspaceKind, + surfaceError = false, +) { + const workspaceKind = overrideKind || getState().session.workspaceKind await dispatch(cancelConversationLoad()) - dispatch(chatActions.clearChat()) - dispatch(sessionActions.setCurrentSession('')) dispatch(uiActions.setView('chat')) try { - const resp = await api.newSession() + const resp = await api.newSession( + undefined, + undefined, + workspaceKind, + ) + dispatch(chatActions.clearChat()) + dispatch(sessionActions.setCurrentSession('')) dispatch(sessionActions.setCurrentSession(resp.session_id)) + dispatch(sessionActions.setProjectPath(resp.project || resp.workspace_key || resp.pwd || getState().session.projectPath)) + dispatch(sessionActions.setWorkspaceKind(resp.workspace_kind || workspaceKind)) if (resp.provider !== undefined) dispatch(modelActions.setProvider(resp.provider)) if (resp.model !== undefined) dispatch(modelActions.setModel(resp.model)) if (resp.agent !== undefined) dispatch(modelActions.setAgent(resp.agent)) if (resp.mode !== undefined) dispatch(modelActions.setMode(normalizeMode(resp.mode))) - } catch { - // surfaced via health/gate + } catch (error) { + if (surfaceError) throw error + // Existing global-new-task behavior stays quiet; health/gate reconciles. } +} + +export const startNewChat = createAsyncThunk('session/startNew', async (_, { dispatch, getState }) => { + await provisionNewChat(dispatch as AppDispatch, () => getState() as RootState) +}) + +export const startScratchChat = createAsyncThunk('session/startScratch', async (_, { dispatch, getState }) => { + await provisionNewChat(dispatch as AppDispatch, () => getState() as RootState, 'scratch', true) }) export const replaySession = createAsyncThunk( diff --git a/web/src/components/AuthGate.tsx b/web/src/components/AuthGate.tsx index 293df0c..d517066 100644 --- a/web/src/components/AuthGate.tsx +++ b/web/src/components/AuthGate.tsx @@ -31,6 +31,7 @@ export function AuthGate() { dispatch(modelActions.setServerVersion(h.version)) dispatch(modelActions.setImageSupport(!!h.image_support)) dispatch(sessionActions.setProjectPath(h.pwd)) + dispatch(sessionActions.setWorkspaceKind(h.workspace_kind)) dispatch(sessionActions.setCurrentSession(h.session_id || '')) dispatch(chatActions.setRunning(!!h.running)) await dispatch(loadWorkspaceState()) diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 802c686..fe28ab0 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -81,12 +81,15 @@ export function ChatView({ readOnly }: ChatViewProps) { return null }) const projectPath = useAppSelector((s) => s.session.projectPath) + const workspaceKind = useAppSelector((s) => s.session.workspaceKind) const backdropKind = useAppSelector((s) => { const provider = s.model.providers.find((candidate) => candidate.id === s.model.providerName) const model = provider?.models.find((candidate) => candidate.id === s.model.modelName) return modelBackdropKind([provider?.kind, s.model.providerName, provider?.name, s.model.modelName, model?.name]) }) - const project = projectName(projectPath) || 'jcode' + const project = workspaceKind === 'scratch' + ? t('workspace.noProject') + : projectName(projectPath) || 'jcode' if (readOnly) { return ( @@ -124,7 +127,9 @@ export function ChatView({ readOnly }: ChatViewProps) { // Welcome screen: centered hero + elevated composer (no messages yet). if (!hasMessages) { const subtitle = t('welcome.subtitle') - const title = t('welcome.startIn').replace('{project}', project) + const title = workspaceKind === 'scratch' + ? t('welcome.startWithoutProject') + : t('welcome.startIn').replace('{project}', project) const [subtitleBefore, subtitleAfter] = subtitle.split('{kbd}') return ( diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx index 5290cb4..01df06c 100644 --- a/web/src/components/CommandPalette.tsx +++ b/web/src/components/CommandPalette.tsx @@ -60,6 +60,7 @@ export function CommandPalette() { uuid: task.uuid, project: task.project || '', title: task.title, + workspaceKind: task.workspace_kind, })) } finally { setOpening(false) @@ -90,7 +91,7 @@ export function CommandPalette() { id: `task-${task.uuid}`, group: t('commandPalette.groups.tasks'), label: task.title || `${task.uuid.slice(0, 8)}...`, - hint: workspaceName(task.project), + hint: task.workspace_kind === 'scratch' ? t('workspace.noProject') : workspaceName(task.project), Icon: ChatBubbleLeftIcon, run: () => openTask(task), })), diff --git a/web/src/components/DesktopTitlebar.tsx b/web/src/components/DesktopTitlebar.tsx index 9546225..0245905 100644 --- a/web/src/components/DesktopTitlebar.tsx +++ b/web/src/components/DesktopTitlebar.tsx @@ -51,6 +51,7 @@ export function DesktopTitlebar(props: Props) { const tasks = useAppSelector((s) => s.session.tasks) const sessions = useAppSelector((s) => s.session.sessions) const projectPath = useAppSelector((s) => s.session.projectPath) + const currentWorkspaceKind = useAppSelector((s) => s.session.workspaceKind) const currentProvider = useAppSelector((s) => s.model.providerName) const currentModel = useAppSelector((s) => s.model.modelName) @@ -64,7 +65,10 @@ export function DesktopTitlebar(props: Props) { ) const taskTitle = activeTask?.title?.trim() || activeSession?.title?.trim() || t('sidebar.untitled') const activeProject = activeTask?.project || projectPath - const projectLabel = workspaceName(activeProject) + const activeWorkspaceKind = activeTask?.workspace_kind || currentWorkspaceKind + const projectLabel = activeWorkspaceKind === 'scratch' + ? t('workspace.noProject') + : workspaceName(activeProject) const modelLabel = [activeTask?.provider || currentProvider, activeTask?.model || currentModel] .filter(Boolean) .join(' / ') @@ -109,6 +113,7 @@ export function DesktopTitlebar(props: Props) { title={taskTitle} project={activeProject} projectLabel={projectLabel} + workspaceKind={activeWorkspaceKind} branch={branch} model={modelLabel} pinned={!!activeTask?.pinned} @@ -135,6 +140,7 @@ interface TaskDetailsProps { title: string project: string projectLabel: string + workspaceKind: 'project' | 'scratch' branch: string model: string pinned: boolean @@ -149,6 +155,7 @@ function TaskDetails({ title, project, projectLabel, + workspaceKind, branch, model, pinned, @@ -273,6 +280,11 @@ function TaskDetails({ ) : (

{title}

+ {workspaceKind === 'scratch' && ( + + {t('workspace.noProject')} + + )} { + const actual = await importOriginal() + return { + ...actual, + api: { + ...actual.api, + cloudStatus: vi.fn().mockRejectedValue(new Error('not configured')), + cloudPairings: vi.fn().mockResolvedValue({ pairings: [] }), + }, + } +}) + +beforeEach(async () => { + cleanup() + await i18n.changeLanguage('en') + store.dispatch(sessionActions.setCurrentSession('scratch-new')) + store.dispatch(sessionActions.setProjectPath('/tmp/.jcode/workspace/2026-08-19-002')) + store.dispatch(sessionActions.setWorkspaceKind('scratch')) + store.dispatch(sessionActions.setTasks([ + { + uuid: 'scratch-old', + project: '/tmp/.jcode/workspace/2026-08-19-001', + workspace_kind: 'scratch', + created_at: '2026-08-19T09:00:00Z', + updated_at: '2026-08-19T09:00:00Z', + provider: 'openai', model: 'gpt-5', title: 'First scratch task', + pinned: false, archived: false, unread: false, + }, + { + uuid: 'scratch-new', + project: '/tmp/.jcode/workspace/2026-08-19-002', + workspace_kind: 'scratch', + created_at: '2026-08-19T11:00:00Z', + updated_at: '2026-08-19T11:00:00Z', + provider: 'openai', model: 'gpt-5', title: 'Second scratch task', + pinned: false, archived: false, unread: false, + }, + { + uuid: 'project-task', + project: '/work/jcode', + workspace_kind: 'project', + created_at: '2026-08-19T10:00:00Z', + updated_at: '2026-08-19T10:00:00Z', + provider: 'openai', model: 'gpt-5', title: 'Project task', + pinned: false, archived: false, unread: false, + }, + ])) + store.dispatch(sessionActions.setProjectTimes([ + { path: '/tmp/.jcode/workspace/2026-08-19-001', updated_at: '2026-08-19T09:00:00Z', workspace_kind: 'scratch' }, + { path: '/tmp/.jcode/workspace/2026-08-19-002', updated_at: '2026-08-19T11:00:00Z', workspace_kind: 'scratch' }, + { path: '/work/jcode', updated_at: '2026-08-19T10:00:00Z', workspace_kind: 'project' }, + ])) +}) + +describe('Sidebar no-project grouping', () => { + it('merges scratch paths into one recent no-project group', async () => { + const { container } = render( + + + , + ) + + await waitFor(() => expect(screen.getByText('Second scratch task')).toBeTruthy()) + expect(screen.getAllByText('No project')).toHaveLength(1) + expect(screen.queryByText('2026-08-19-001')).toBeNull() + expect(screen.queryByText('2026-08-19-002')).toBeNull() + const groupNames = [...container.querySelectorAll('.sb-project-name')].map((node) => node.textContent) + expect(groupNames).toEqual(['No project', 'jcode']) + }) +}) diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 691d86a..44b0db8 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -16,6 +16,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { PlusIcon, + ChatBubbleLeftRightIcon, ChevronRightIcon, FolderIcon, FolderOpenIcon, @@ -32,9 +33,9 @@ import { } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' import { useAppDispatch, useAppSelector } from '../app/hooks' -import { uiActions, sessionActions, chatActions, remoteConnectionActions, loadWorkspaceState, openConversation, startNewChat } from '../app/store' +import { uiActions, sessionActions, chatActions, remoteConnectionActions, loadWorkspaceState, openConversation, startNewChat, startScratchChat } from '../app/store' import { api } from '../lib/api' -import type { TaskItem } from '../lib/types' +import type { TaskItem, WorkspaceKind } from '../lib/types' import { ThemeToggle } from './ThemeToggle' import { CloudBadge } from './CloudBadge' import { @@ -47,12 +48,14 @@ import { import { isRemotePath, openRemoteConnect, parseRemoteLabel } from '../lib/remote' const FILTERS_KEY = 'jcode_sidebar_filters' +const SCRATCH_GROUP_KEY = '__jcode_scratch__' // ─── Enriched row: a session joined with its task metadata ─── interface SessionRow { uuid: string project: string + workspaceKind: WorkspaceKind title: string created_at: string updated_at: string @@ -82,6 +85,7 @@ interface SidebarGroup { kind: 'project' | 'date' label: string path?: string + workspaceKind: WorkspaceKind items: SessionRow[] } @@ -93,6 +97,7 @@ export function Sidebar() { const sessions = useAppSelector((s) => s.session.sessions) const tasks = useAppSelector((s) => s.session.tasks) const projectTimes = useAppSelector((s) => s.session.projectTimes) + const projectKinds = useAppSelector((s) => s.session.projectKinds) const currentSessionId = useAppSelector((s) => s.session.currentSessionId) // Latest currentSessionId readable from async handlers (deleteItem) after an // await, when the render-scope value is stale: if the user navigated away @@ -100,6 +105,7 @@ export function Sidebar() { const currentSessionRef = useRef(currentSessionId) currentSessionRef.current = currentSessionId const activePath = useAppSelector((s) => s.session.projectPath) + const activeWorkspaceKind = useAppSelector((s) => s.session.workspaceKind) const activeView = useAppSelector((s) => s.ui.activeView) const [filters, setFilters] = useState(() => loadFilters()) @@ -133,14 +139,15 @@ export function Sidebar() { }, [filters]) useEffect(() => { - if (!activePath) return + const activeGroup = activeWorkspaceKind === 'scratch' ? SCRATCH_GROUP_KEY : activePath + if (!activeGroup) return setExpanded((prev) => { - if (prev.has(activePath)) return prev + if (prev.has(activeGroup)) return prev const next = new Set(prev) - next.add(activePath) + next.add(activeGroup) return next }) - }, [activePath]) + }, [activePath, activeWorkspaceKind]) // Vue's sidebar is task-first: /api/tasks is the cross-project source of // truth, while /api/sessions only describes the active project. Keep a small @@ -154,6 +161,7 @@ export function Sidebar() { out.push({ uuid: s.uuid, project: activePath, + workspaceKind: activeWorkspaceKind, title: s.title || '', created_at: s.created_at || '', updated_at: s.created_at || '', @@ -164,7 +172,7 @@ export function Sidebar() { }) } return out - }, [tasks, sessions, activePath]) + }, [tasks, sessions, activePath, activeWorkspaceKind]) // After the first non-empty paint, animate only newly-added rows (not the // initial hydrate of the whole list). @@ -199,16 +207,24 @@ export function Sidebar() { const projects = useMemo(() => { const map = new Map() - if (activePath) map.set(activePath, projectName(activePath)) + let hasScratch = activeWorkspaceKind === 'scratch' + if (activePath && activeWorkspaceKind !== 'scratch') map.set(activePath, projectName(activePath)) for (const r of rows) { - if (r.project) map.set(r.project, projectName(r.project)) + if (r.workspaceKind === 'scratch') { + hasScratch = true + } else if (r.project) { + map.set(r.project, projectName(r.project)) + } } - return [...map].map(([path, name]) => ({ path, name })).sort((a, b) => { + const actual = [...map].map(([path, name]) => ({ path, name })).sort((a, b) => { if (a.path === activePath) return -1 if (b.path === activePath) return 1 - return a.name.localeCompare(b.name) + const byName = a.name.localeCompare(b.name) + return byName !== 0 ? byName : a.path.localeCompare(b.path) }) - }, [rows, activePath]) + if (hasScratch) actual.push({ path: SCRATCH_GROUP_KEY, name: t('workspace.noProject') }) + return actual + }, [rows, activePath, activeWorkspaceKind, t]) const projectFilter = useMemo(() => { if (!filters.project) return '' @@ -228,7 +244,8 @@ export function Sidebar() { if (r.uuid === currentSessionId) return true if (filters.status === 'active' && r.archived) return false if (filters.status === 'archived' && !r.archived) return false - if (projectFilter && r.project !== projectFilter) return false + if (projectFilter === SCRATCH_GROUP_KEY && r.workspaceKind !== 'scratch') return false + if (projectFilter && projectFilter !== SCRATCH_GROUP_KEY && r.project !== projectFilter) return false if (filters.lastActivity !== 'all') { const ts = r.updated_at || r.created_at || '' const then = new Date(ts).getTime() @@ -269,22 +286,26 @@ export function Sidebar() { if (filters.groupBy === 'project') { const map = new Map() for (const r of sorted) { - const key = r.project || activePath || '' + const key = r.workspaceKind === 'scratch' ? SCRATCH_GROUP_KEY : r.project || activePath || '' const arr = map.get(key) if (arr) arr.push(r) else map.set(key, [r]) } const paths = projectFilter ? new Set([projectFilter]) : new Set(map.keys()) const narrowing = !!projectFilter || filters.status === 'archived' || filters.lastActivity !== 'all' - if (activePath && (map.has(activePath) || !narrowing)) paths.add(activePath) - const projectGroups = [...paths].map((path) => ({ + const activeGroupKey = activeWorkspaceKind === 'scratch' ? SCRATCH_GROUP_KEY : activePath + if (activeGroupKey && (map.has(activeGroupKey) || !narrowing)) paths.add(activeGroupKey) + const projectGroups = [...paths].map((key) => ({ kind: 'project' as const, - key: path || 'current', - path, - label: projectName(path), - items: map.get(path) || [], + key: key || 'current', + path: key === SCRATCH_GROUP_KEY ? undefined : key, + workspaceKind: (key === SCRATCH_GROUP_KEY ? 'scratch' : 'project') as WorkspaceKind, + label: key === SCRATCH_GROUP_KEY ? t('workspace.noProject') : projectName(key), + items: map.get(key) || [], })) - return projectGroups.sort((a, b) => compareProjectGroups(a, b, activePath, projectTimes)) + return projectGroups.sort((a, b) => compareProjectGroups( + a, b, activePath, activeWorkspaceKind, projectTimes, projectKinds, + )) } const map = new Map() @@ -298,9 +319,10 @@ export function Sidebar() { kind: 'date' as const, key: k, label: t(`sidebar.dateBucket.${k}`), + workspaceKind: 'project' as const, items: map.get(k)!, })) - }, [sorted, filters.groupBy, filters.status, filters.lastActivity, projectFilter, activePath, projectTimes, now, t]) + }, [sorted, filters.groupBy, filters.status, filters.lastActivity, projectFilter, activePath, activeWorkspaceKind, projectTimes, projectKinds, now, t]) const duplicateProjectNames = useMemo(() => { const counts = new Map() @@ -348,6 +370,7 @@ export function Sidebar() { uuid: row.uuid, project: row.project, title: row.title, + workspaceKind: row.workspaceKind, })) } @@ -500,6 +523,7 @@ export function Sidebar() { try { const resp = await api.switchProject(path) dispatch(sessionActions.setProjectPath(resp.pwd || path)) + dispatch(sessionActions.setWorkspaceKind(resp.workspace_kind)) await dispatch(loadWorkspaceState()) } catch { dispatch(chatActions.addMessage({ @@ -514,6 +538,12 @@ export function Sidebar() { setExpanded((prev) => new Set(prev).add(path)) } + async function newScratchTask() { + dispatch(uiActions.setView('chat')) + await dispatch(startScratchChat()) + setExpanded((prev) => new Set(prev).add(SCRATCH_GROUP_KEY)) + } + const ctxRow = ctx?.row return ( @@ -562,16 +592,21 @@ export function Sidebar() { ) : ( groups.map((g) => { const isProject = g.kind === 'project' + const isScratch = isProject && g.workspaceKind === 'scratch' const open = !isProject || expanded.has(g.key) - const activeProject = isProject && g.path === activePath - const ProjectIcon = g.path && isRemotePath(g.path) ? ServerIcon : activeProject ? FolderOpenIcon : FolderIcon + const activeProject = isProject && (isScratch + ? activeWorkspaceKind === 'scratch' + : activeWorkspaceKind !== 'scratch' && g.path === activePath) + const ProjectIcon = isScratch + ? ChatBubbleLeftRightIcon + : g.path && isRemotePath(g.path) ? ServerIcon : activeProject ? FolderOpenIcon : FolderIcon return (
{isProject ? (
toggleGroup(g.key)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -588,19 +623,20 @@ export function Sidebar() { {projectParentHint(g.path)} )} - {relativeTime((g.path && projectTimes[g.path]) || aggregate(g.items).lastTs, now, t)} + {relativeTime(groupLastActivity(g, projectTimes, projectKinds), now, t)} {!open && g.items.some((row) => row.running) && (