Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions internal/browser/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ func (b *Bridge) HandleWS(w http.ResponseWriter, r *http.Request) {
}
token := hello.Token

_ = conn.WriteJSON(map[string]any{"type": "welcome", "token": token})

// Register before writing the welcome so that "welcome received" implies
// Connected() == true for the peer; otherwise a client that acts on the
// welcome immediately could observe the bridge as still offline.
bc := newBridgeConn(conn)
b.mu.Lock()
if b.conn != nil {
Expand All @@ -97,6 +98,18 @@ func (b *Bridge) HandleWS(w http.ResponseWriter, r *http.Request) {
b.conn = bc
b.mu.Unlock()

// Write the welcome through bc so it serializes with command traffic:
// registration above already made the conn usable by Backend() callers.
if err := bc.writeJSON(map[string]any{"type": "welcome", "token": token}); err != nil {
b.mu.Lock()
if b.conn == bc {
b.conn = nil
}
b.mu.Unlock()
_ = conn.Close()
return
}

config.Logger().Printf("[browser] extension connected")
go bc.keepAlive()
bc.readLoop()
Expand Down Expand Up @@ -143,12 +156,22 @@ type bridgeEnvelope struct {
// and the popup flaps to "Reconnecting…". keepAliveWait is the read side: if no
// frame (pong, alarm ping, or command reply) arrives within two ping periods,
// treat the extension as dead and tear the socket down.
// vars, not consts, so tests can shrink them.
// vars, not consts, so tests can shrink them. Held as atomic nanoseconds:
// keepAlive/readLoop goroutines outlive the test that spawned their conn, so
// plain variables would race with a later test re-tuning the values.
var (
keepAlivePing = 15 * time.Second
keepAliveWait = 40 * time.Second
keepAlivePing = int64(15 * time.Second)
keepAliveWait = int64(40 * time.Second)
)

func keepAlivePingDuration() time.Duration {
return time.Duration(atomic.LoadInt64(&keepAlivePing))
}

func keepAliveWaitDuration() time.Duration {
return time.Duration(atomic.LoadInt64(&keepAliveWait))
}

type bridgeConn struct {
ws *websocket.Conn
writeMu sync.Mutex
Expand All @@ -175,7 +198,7 @@ func (c *bridgeConn) writeJSON(v any) error {
// and the socket stays up between commands. It exits when the read loop closes
// the conn.
func (c *bridgeConn) keepAlive() {
t := time.NewTicker(keepAlivePing)
t := time.NewTicker(keepAlivePingDuration())
defer t.Stop()
for {
select {
Expand All @@ -201,7 +224,7 @@ func newBridgeConn(ws *websocket.Conn) *bridgeConn {
}

func (c *bridgeConn) readLoop() {
_ = c.ws.SetReadDeadline(time.Now().Add(keepAliveWait))
_ = c.ws.SetReadDeadline(time.Now().Add(keepAliveWaitDuration()))
for {
var env bridgeEnvelope
if err := c.ws.ReadJSON(&env); err != nil {
Expand All @@ -216,7 +239,7 @@ func (c *bridgeConn) readLoop() {
return
}
// Any inbound frame proves the extension is alive; extend the window.
_ = c.ws.SetReadDeadline(time.Now().Add(keepAliveWait))
_ = c.ws.SetReadDeadline(time.Now().Add(keepAliveWaitDuration()))
switch env.Type {
case "ping", "pong":
// Keepalive traffic (the extension's own alarm ping, or a pong to
Expand Down
11 changes: 8 additions & 3 deletions internal/browser/bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -149,9 +150,13 @@ func TestBridgeCDPForwarding(t *testing.T) {
// ping, no command traffic) must still receive server pings; and if it never
// answers, the read watchdog must eventually drop it.
func TestBridgeKeepAlivePing(t *testing.T) {
oldPing, oldWait := keepAlivePing, keepAliveWait
keepAlivePing, keepAliveWait = 20*time.Millisecond, 120*time.Millisecond
t.Cleanup(func() { keepAlivePing, keepAliveWait = oldPing, oldWait })
oldPing, oldWait := keepAlivePingDuration(), keepAliveWaitDuration()
atomic.StoreInt64(&keepAlivePing, int64(20*time.Millisecond))
atomic.StoreInt64(&keepAliveWait, int64(120*time.Millisecond))
t.Cleanup(func() {
atomic.StoreInt64(&keepAlivePing, int64(oldPing))
atomic.StoreInt64(&keepAliveWait, int64(oldWait))
})

b, wsURL := bridgeServer(t)
token := b.IssueToken()
Expand Down
100 changes: 100 additions & 0 deletions internal/session/lastsession.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package session

import (
"encoding/json"
"os"
"path/filepath"

"github.com/cnjack/jcode/internal/config"
)

// lastSessionFile is the on-disk structure of last_session.json: the most
// recently foregrounded session per project, so a web/desktop client can
// return to the conversation that was open before a restart.
type lastSessionFile struct {
Projects map[string]string `json:"projects"` // project path → session uuid
}

func lastSessionPath() (string, error) {
dir, err := config.SessionsDir()
if err != nil {
return "", err
}
Comment on lines +18 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Wrap the returned error.

As per coding guidelines, use fmt.Errorf("tool_name: %w", err) for wrapped errors in non-tool code.

🛠️ Proposed fix
 func lastSessionPath() (string, error) {
 	dir, err := config.SessionsDir()
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("session: %w", err)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func lastSessionPath() (string, error) {
dir, err := config.SessionsDir()
if err != nil {
return "", err
}
func lastSessionPath() (string, error) {
dir, err := config.SessionsDir()
if err != nil {
return "", fmt.Errorf("session: %w", err)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/session/lastsession.go` around lines 18 - 22, Update lastSessionPath
to wrap the error returned by config.SessionsDir with fmt.Errorf, adding the
tool name as context while preserving the original error via %w; add the
required fmt import if needed.

Source: Coding guidelines

return filepath.Join(dir, "last_session.json"), nil
}

// SaveLastSession records id as the last foregrounded session for project.
// Best-effort: persistence must never break session switching, and callers
// run outside any engine lock (file I/O).
func SaveLastSession(project, id string) {
if project == "" || id == "" || ValidateSessionID(id) != nil {
return
}
indexMu.Lock()
defer indexMu.Unlock()

p, err := lastSessionPath()
if err != nil {
return
}
var f lastSessionFile
if data, readErr := os.ReadFile(p); readErr == nil {
_ = json.Unmarshal(data, &f) // corrupt file → start fresh
}
if f.Projects == nil {
f.Projects = map[string]string{}
}
if f.Projects[project] == id {
return
}
f.Projects[project] = id

if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
return
}
data, err := json.Marshal(&f)
if err != nil {
return
}
// tmp + rename (same pattern as the session index) so a crash mid-write
// never leaves a truncated file.
tmp := p + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return
}
_ = os.Rename(tmp, p)
}

// LoadLastSession returns the last foregrounded session uuid for project, or
// "" when none is recorded — or when the recorded session no longer exists on
// disk (deleted, or a "new chat" that was never written), so callers fall
// back to a fresh session instead of resurrecting a stale id.
func LoadLastSession(project string) string {
if project == "" {
return ""
}
p, err := lastSessionPath()
if err != nil {
return ""
}
data, err := os.ReadFile(p)
if err != nil {
return ""
}
var f lastSessionFile
if err := json.Unmarshal(data, &f); err != nil {
return ""
}
id := f.Projects[project]
if id == "" || ValidateSessionID(id) != nil {
return ""
}
dir, err := config.SessionsDir()
if err != nil {
return ""
}
if _, err := os.Stat(filepath.Join(dir, id+".json")); err != nil {
return ""
}
return id
}
79 changes: 79 additions & 0 deletions internal/session/lastsession_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package session

import (
"os"
"path/filepath"
"testing"

"github.com/cnjack/jcode/internal/config"
)

// TestLastSessionRoundTrip covers save → load keyed per project.
func TestLastSessionRoundTrip(t *testing.T) {
t.Setenv("HOME", t.TempDir())
Comment on lines +12 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix home directory mocking for Windows test isolation.

Go's os.UserHomeDir() natively relies on the USERPROFILE environment variable on Windows, not HOME. Mocking only HOME means the tests will read from and write to the developer's actual home directory on Windows machines, which can pollute the workspace or fail due to permissions. Set both variables to ensure robust cross-platform test isolation.

  • internal/session/lastsession_test.go#L12-L13: Declare tmp := t.TempDir() and use it to set both HOME and USERPROFILE.
  • internal/session/lastsession_test.go#L57-L58: Set both HOME and USERPROFILE with t.TempDir().
  • internal/session/lastsession_test.go#L67-L68: Set both HOME and USERPROFILE with t.TempDir().
📍 Affects 1 file
  • internal/session/lastsession_test.go#L12-L13 (this comment)
  • internal/session/lastsession_test.go#L57-L58
  • internal/session/lastsession_test.go#L67-L68
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/session/lastsession_test.go` around lines 12 - 13, Update
TestLastSessionRoundTrip in internal/session/lastsession_test.go at lines 12-13
to store t.TempDir() in tmp and set both HOME and USERPROFILE to tmp; at lines
57-58 and 67-68, set both environment variables to isolated temporary
directories using t.TempDir(), ensuring all last-session tests avoid the real
home directory on every platform.


// Nothing recorded yet → empty.
if got := LoadLastSession("/proj/a"); got != "" {
t.Fatalf("expected empty before any save, got %q", got)
}

// The loader only accepts sessions whose JSONL exists (a "new chat" that
// was never written must not resurrect), so materialize the session files.
dir, err := config.SessionsDir()
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
for _, id := range []string{"11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"} {
if err := os.WriteFile(filepath.Join(dir, id+".json"), []byte("{}\n"), 0644); err != nil {
t.Fatal(err)
}
}

SaveLastSession("/proj/a", "11111111-1111-1111-1111-111111111111")
SaveLastSession("/proj/b", "22222222-2222-2222-2222-222222222222")

if got := LoadLastSession("/proj/a"); got != "11111111-1111-1111-1111-111111111111" {
t.Fatalf("proj/a: got %q", got)
}
if got := LoadLastSession("/proj/b"); got != "22222222-2222-2222-2222-222222222222" {
t.Fatalf("proj/b: got %q", got)
}
if got := LoadLastSession("/proj/never-saved"); got != "" {
t.Fatalf("unknown project: expected empty, got %q", got)
}

// Overwrite moves the project's pointer.
SaveLastSession("/proj/a", "22222222-2222-2222-2222-222222222222")
if got := LoadLastSession("/proj/a"); got != "22222222-2222-2222-2222-222222222222" {
t.Fatalf("proj/a after overwrite: got %q", got)
}
}

// TestLastSessionSkipsStaleIDs: a recorded id whose session file disappeared
// (deleted conversation, or an empty chat that never hit disk) loads as "".
func TestLastSessionSkipsStaleIDs(t *testing.T) {
t.Setenv("HOME", t.TempDir())

SaveLastSession("/proj/a", "33333333-3333-3333-3333-333333333333") // file never created
if got := LoadLastSession("/proj/a"); got != "" {
t.Fatalf("stale id: expected empty, got %q", got)
}
}

// TestLastSessionRejectsBadInput: empty/unsafe values are no-ops, not errors.
func TestLastSessionRejectsBadInput(t *testing.T) {
t.Setenv("HOME", t.TempDir())

SaveLastSession("", "11111111-1111-1111-1111-111111111111")
SaveLastSession("/proj/a", "")
SaveLastSession("/proj/a", "../escape")
if got := LoadLastSession("/proj/a"); got != "" {
t.Fatalf("expected empty after bad saves, got %q", got)
}
if got := LoadLastSession(""); got != "" {
t.Fatalf("empty project: expected empty, got %q", got)
}
}
5 changes: 5 additions & 0 deletions internal/web/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,11 @@ func (s *Server) setActiveEngine(eng *Engine) {
s.deleteEngine(prev.taskID)
}
}
// Remember the foregrounded session per project (keyed by the engine's own
// pwd, so remote workspaces never clobber the local entry) — health reports
// it after a restart so clients return to their last conversation. Runs
// outside s.mu: this is best-effort file I/O.
session.SaveLastSession(eng.pwd, eng.taskID)
}

// deleteEngine removes a task engine from the map and tears it down (stops its
Expand Down
16 changes: 15 additions & 1 deletion internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,14 +496,28 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
}

provider, mdl, modeStr := eng.modelSnapshot()
sessionID := eng.recUUID()
// After a restart the bootstrap engine is a fresh throwaway (no recording,
// not running) whose UUID has no history to restore. Report the project's
// last foregrounded session instead so clients boot straight back into the
// conversation that was open when the app was closed. Once the live engine
// has real state it always reports its own UUID.
eng.emu.Lock()
throwaway := (eng.recorder == nil || !eng.recorder.HasRecording()) && !eng.running.Load()
eng.emu.Unlock()
if throwaway {
if last := session.LoadLastSession(eng.pwd); last != "" {
sessionID = last
}
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"version": s.version,
"pwd": eng.pwd,
"provider": provider,
"model": mdl,
"mode": modeStr,
"session_id": eng.recUUID(),
"session_id": sessionID,
"running": eng.running.Load(),
"image_support": s.currentModelSupportsImage(eng),
"auth_required": s.requireAuth,
Expand Down
18 changes: 14 additions & 4 deletions web/src/app/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import {
editMessage,
chatActions,
} from './store'
import type { ChatImage, AskUserAnswer } from 'jcode-ui-core'
import type { ChatImage, AskUserAnswer, QueuedMessage } from 'jcode-ui-core'

/** Referentially-stable empty queue for sessions without a stash (keeps the
* runtime selector from re-rendering on every store change). */
const EMPTY_QUEUE: QueuedMessage[] = []

/** Build the ChatRuntime once. The store singleton is stable, so the runtime
* is too. */
Expand All @@ -39,13 +43,19 @@ export function useChatRuntime(): ChatRuntime {
tokenSnapshot: s.chat.tokenSnapshot,
goal: s.chat.goal,
todos: s.chat.todos,
queued: s.chat.queued,
// The composer shows only the FOREGROUND session's type-ahead queue;
// other sessions' stashes stay in the store until their agentDone.
queued: s.chat.queuedBySession[s.session.currentSessionId] ?? EMPTY_QUEUE,
}),
actions: {
sendMessage: (text, images) => store.dispatch(sendMessage({ text, images: images as ChatImage[] | undefined })),
enqueueMessage: (text, images) =>
store.dispatch(chatActions.enqueueMessage({ id: `q_${Date.now()}`, text, images: images as ChatImage[] | undefined })),
removeQueuedMessage: (id) => store.dispatch(chatActions.removeQueued(id)),
store.dispatch(chatActions.enqueueMessage({
sessionId: store.getState().session.currentSessionId,
message: { id: `q_${Date.now()}`, text, images: images as ChatImage[] | undefined },
})),
removeQueuedMessage: (id) =>
store.dispatch(chatActions.removeQueued({ sessionId: store.getState().session.currentSessionId, id })),
stop: () => store.dispatch(stopAgent()),
resolveApproval: (id, approved, approveAll) =>
store.dispatch(resolveApproval({ id, approved, approveAll })),
Expand Down
Loading
Loading