Skip to content

Commit c09a240

Browse files
committed
feat(sessions): auto-mkdir workdir, longer initial-prompt wait, 'stuck' → 'IDLE'
Three UX fixes from mobile testing: 1. POST /api/sessions auto-creates the workdir when it doesn't exist (falls through to 400 only if the parent is unwriteable). Previously the handler returned bad_workdir and left the user to mkdir manually before retrying. 2. SendInitialPrompt waited 3s before firing the Enter keybind — too fast: the text landed in claude's input but the submit keybind raced ahead of claude's TUI attach and the prompt never executed. Bump to 8s plus a 300ms gap between keys and Enter. 3. AttentionLabel maps 'stuck' state to 'Idle'. The previous label (fall-through to literal 'stuck' upper-cased) read as an alarm rather than a neutral no-activity signal. Also added the other missing state→label mappings (context_imminent, yolo_unchecked, last_error_call, quota_high) so future states don't fall through to raw enum names.
1 parent 32a9f41 commit c09a240

4 files changed

Lines changed: 58 additions & 9 deletions

File tree

internal/serve/api/create.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package api
55

66
import (
77
"encoding/json"
8+
"errors"
9+
"io/fs"
810
"net/http"
911
"os"
1012
"path/filepath"
@@ -68,9 +70,24 @@ func CreateSession(src InputSessionSource, sp CreateSpawner, lp CreateLookPath)
6870
}
6971
info, err := os.Stat(body.Workdir)
7072
if err != nil {
71-
writeInputErr(w, http.StatusBadRequest, "bad_workdir",
72-
"workdir stat: "+err.Error())
73-
return
73+
if !errors.Is(err, fs.ErrNotExist) {
74+
writeInputErr(w, http.StatusBadRequest, "bad_workdir",
75+
"workdir stat: "+err.Error())
76+
return
77+
}
78+
// Auto-create the workdir so users can spawn sessions for
79+
// directories that don't exist yet (fresh project scratchpad).
80+
if mkErr := os.MkdirAll(body.Workdir, 0o755); mkErr != nil {
81+
writeInputErr(w, http.StatusBadRequest, "bad_workdir",
82+
"workdir mkdir: "+mkErr.Error())
83+
return
84+
}
85+
info, err = os.Stat(body.Workdir)
86+
if err != nil {
87+
writeInputErr(w, http.StatusInternalServerError, "bad_workdir",
88+
"workdir stat after mkdir: "+err.Error())
89+
return
90+
}
7491
}
7592
if !info.IsDir() {
7693
writeInputErr(w, http.StatusBadRequest, "workdir_not_dir",

internal/serve/api/create_test.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,9 @@ func TestCreate_RelativeWorkdir(t *testing.T) {
202202
}
203203
}
204204

205-
func TestCreate_MissingWorkdir(t *testing.T) {
205+
func TestCreate_UncreatableWorkdir(t *testing.T) {
206+
// `/definitely/…` — MkdirAll can't create /definitely from a
207+
// non-root test process, so this exercises the mkdir-failed branch.
206208
h := api.CreateSession(&fakeCreateProj{}, &fakeCreateSpawner{}, fakeLookPath{ok: true})
207209
rec := httptest.NewRecorder()
208210
h(rec, createReq(t, map[string]string{"workdir": "/definitely/not/here/xyz"}))
@@ -214,6 +216,22 @@ func TestCreate_MissingWorkdir(t *testing.T) {
214216
}
215217
}
216218

219+
func TestCreate_AutoCreatesMissingWorkdir(t *testing.T) {
220+
parent := tempDir(t)
221+
newDir := filepath.Join(parent, "newly-created")
222+
proj := &fakeCreateProj{sess: map[string]session.Session{}}
223+
spawn := &fakeCreateSpawner{returnSess: session.Session{UUID: "u", Mode: "yolo"}}
224+
h := api.CreateSession(proj, spawn, fakeLookPath{ok: true})
225+
rec := httptest.NewRecorder()
226+
h(rec, createReq(t, map[string]string{"workdir": newDir}))
227+
if rec.Code != http.StatusCreated {
228+
t.Fatalf("status = %d (%s), want 201", rec.Code, rec.Body.String())
229+
}
230+
if info, err := os.Stat(newDir); err != nil || !info.IsDir() {
231+
t.Fatalf("workdir not auto-created: err=%v", err)
232+
}
233+
}
234+
217235
func TestCreate_FileInsteadOfDir(t *testing.T) {
218236
dir := tempDir(t)
219237
f := filepath.Join(dir, "file")

internal/serve/server.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -902,17 +902,26 @@ func (c createSpawner) Spawn(name, workdir string) (session.Session, error) {
902902
})
903903
}
904904

905-
// SendInitialPrompt fires `text` into the new session's pane after a
906-
// short delay so claude has time to boot and show its prompt. Runs
907-
// in a goroutine — fire-and-forget; errors are logged, not returned.
905+
// SendInitialPrompt fires `text` into the new session's pane after
906+
// claude has had time to boot + render its input prompt. Runs in a
907+
// goroutine — fire-and-forget; errors are logged, not returned.
908+
//
909+
// 8s is empirical: 3s was too fast — the text landed in claude's
910+
// buffer but the Enter keybind fired before the TUI had attached
911+
// its input handler, so the prompt appeared on screen but was
912+
// never submitted. 8s reliably catches the post-splash prompt on
913+
// cold-start machines.
908914
func (c createSpawner) SendInitialPrompt(name, text string) {
909915
go func() {
910-
time.Sleep(3 * time.Second)
916+
time.Sleep(8 * time.Second)
911917
target := name + ":0.0"
912918
if err := c.tmux.SendKeys(target, text); err != nil {
913919
slog.Warn("initial prompt send failed", "session", name, "err", err.Error())
914920
return
915921
}
922+
// Small gap between keystrokes and Enter so claude has time
923+
// to register the final character before the submit.
924+
time.Sleep(300 * time.Millisecond)
916925
if err := c.tmux.SendEnter(target); err != nil {
917926
slog.Warn("initial prompt enter failed", "session", name, "err", err.Error())
918927
}

ui/src/components/AttentionLabel.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ import { cn } from "@/lib/utils";
33

44
const HUMAN: Record<string, string> = {
55
error_burst: "Error burst",
6+
stuck: "Idle",
67
stalled: "Stalled",
78
quota_low: "Quota low",
8-
permission_request: "Permission",
9+
quota_high: "Quota high",
910
context_high: "Context high",
11+
context_imminent: "Context full",
12+
permission_request: "Permission",
1013
long_session: "Long session",
1114
tmux_dead: "Tmux dead",
15+
yolo_unchecked: "Unchecked",
16+
last_error_call: "Last call errored",
1217
};
1318

1419
function humanize(state: string): string {

0 commit comments

Comments
 (0)