返回 DeepSeek-Reasonix
remote_tab_commands.go
根目录 / desktop / remote_tab_commands.go
1 package main
2
3 import (
4 "cmp"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "strings"
12 "time"
13
14 "reasonix/internal/config"
15 )
16
17 func (a *App) RenameRemoteProjectSession(hostID, workspace, name, title string) error {
18 if err := setRemoteSessionTitleOverride(hostID, workspace, name, title); err != nil {
19 return err
20 }
21
22 a.remoteTabMu.Lock()
23 var live *remoteTab
24 var liveID string
25 var client *http.Client
26 var base string
27 for _, tab := range a.remoteTabs {
28 if tab.ref.HostID == hostID && tab.ref.Workspace == workspace && tab.client != nil {
29 live = tab
30 liveID = tab.id
31 client = tab.client
32 base = tab.base
33 break
34 }
35 }
36 a.remoteTabMu.Unlock()
37 if live == nil {
38 return nil
39 }
40 if strings.TrimSpace(name) == "" {
41 next := strings.TrimSpace(title)
42 if next == "" {
43 next = a.localizedDefaultTopicTitle()
44 }
45 a.remoteTabMu.Lock()
46 current := a.remoteTabs[liveID]
47 if current != live || current.client != client || !current.session.reset {
48 a.remoteTabMu.Unlock()
49 return nil
50 }
51 changed := current.topicTitle != next
52 current.topicTitle = next
53 meta := remoteTabMetaLocked(current)
54 a.remoteTabMu.Unlock()
55 if changed {
56 a.emitRemoteEvent("remote-tab:updated", meta)
57 a.saveTabsFromRemote()
58 }
59 return nil
60 }
61 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
62 defer cancel()
63 entries, err := serveSessions(ctx, client, base)
64 if err != nil {
65 return nil
66 }
67 for _, entry := range entries {
68 if !entry.Current || entry.Name != name {
69 continue
70 }
71 next := strings.TrimSpace(title)
72 if next == "" {
73 next = strings.TrimSpace(entry.Title)
74 }
75 if next == "" {
76 next = remoteWorkspaceName(workspace)
77 }
78 a.remoteTabMu.Lock()
79 current := a.remoteTabs[liveID]
80 if current != live || current.client != client {
81 a.remoteTabMu.Unlock()
82 return nil
83 }
84 changed := current.topicTitle != next
85 if changed {
86 current.topicTitle = next
87 }
88 meta := remoteTabMetaLocked(current)
89 a.remoteTabMu.Unlock()
90 if changed {
91 a.emitRemoteEvent("remote-tab:updated", meta)
92 a.saveTabsFromRemote()
93 }
94 return nil
95 }
96 return nil
97 }
98
99 func (a *App) resumeRemoteTabSession(tabID, name string) {
100 a.resumeRemoteTabSessionPath(tabID, name, "", "")
101 }
102
103 func (a *App) resumeRemoteTabSessionPath(tabID, name, sessionPath, sessionTitle string) {
104 a.resumeRemoteTabSessionPathForSelection(tabID, name, sessionPath, sessionTitle, 0)
105 }
106
107 func (a *App) resumeRemoteTabSessionPathForSelection(tabID, name, sessionPath, sessionTitle string, selectionRevision uint64) bool {
108 return a.resumeRemoteTabSessionPathForOpenSelection(tabID, name, sessionPath, sessionTitle, selectionRevision, nil)
109 }
110
111 // remoteTabResumeAdmission carries the bridge a resume may use and the route
112 // it restores on failure. admitted=false means the locked pre-check already
113 // answered the request and the caller returns answer unchanged.
114 type remoteTabResumeAdmission struct {
115 admitted bool
116 answer bool
117 client *http.Client
118 base string
119 gen uint64
120 requestedSessionID string
121 failureRoute remoteTabProvisionalResume
122 }
123
124 // admitRemoteTabResume fences a resume against a newer selection and decides
125 // whether the tab's bridge can carry it now.
126 func (a *App) admitRemoteTabResume(tabID string, tab *remoteTab, name, sessionPath, sessionTitle string,
127 selectionRevision uint64, previous *remoteTabOpenSelection) remoteTabResumeAdmission {
128 a.remoteTabMu.Lock()
129 defer a.remoteTabMu.Unlock()
130 if a.remoteTabs[tabID] != tab || (selectionRevision != 0 && tab.selectionRevision != selectionRevision) {
131 return remoteTabResumeAdmission{answer: true}
132 }
133 if tab.client == nil || tab.state != "ready" {
134 if tab.state != "connecting" && tab.state != "reconnecting" {
135 return remoteTabResumeAdmission{}
136 }
137 // Always defer the selection while connecting: the first click after a
138 // fresh desktop start has selectionRevision 0, which once fell through
139 // to a refusal and silently dropped the click while the tunnel came up.
140 requeueRemoteTabOpenSelectionLocked(tab, &remoteTabPendingOpenSelection{
141 name: strings.TrimSpace(name), sessionID: strings.TrimSpace(tab.session.sessionID), path: strings.TrimSpace(sessionPath), title: strings.TrimSpace(sessionTitle),
142 revision: selectionRevision, deferred: true, identityCommitted: true, previous: previous,
143 })
144 return remoteTabResumeAdmission{answer: true}
145 }
146 consumeQueuedRemoteTabOpenSelectionLocked(tab, selectionRevision)
147 admission := remoteTabResumeAdmission{
148 admitted: true, answer: true,
149 client: tab.client, base: tab.base, gen: tab.gen,
150 failureRoute: remoteTabProvisionalResume{
151 targetPath: tab.routing.currentPath, pathRevision: tab.routing.pathRevision,
152 selectionRevision: tab.selectionRevision, previousSelection: previous,
153 },
154 }
155 if strings.TrimSpace(sessionPath) == "" && strings.TrimSpace(tab.session.name) == strings.TrimSpace(name) {
156 // Canonical rows have no legacy path: carry the committed ID into
157 // /resume instead of resolving a display token through the listing.
158 // A synthetic identity row's name is empty; the ID still identifies it.
159 admission.requestedSessionID = strings.TrimSpace(tab.session.sessionID)
160 }
161 return admission
162 }
163
164 func (a *App) resumeRemoteTabSessionPathForOpenSelection(tabID, name, sessionPath, sessionTitle string, selectionRevision uint64, previous *remoteTabOpenSelection) bool {
165 a.remoteTabMu.Lock()
166 tab := a.remoteTabs[tabID]
167 a.remoteTabMu.Unlock()
168 if tab == nil {
169 return true
170 }
171 tab.sessionMu.Lock()
172 defer tab.sessionMu.Unlock()
173 admission := a.admitRemoteTabResume(tabID, tab, name, sessionPath, sessionTitle, selectionRevision, previous)
174 if !admission.admitted {
175 return admission.answer
176 }
177 client, base, gen := admission.client, admission.base, admission.gen
178 requestedSessionID, failureRoute := admission.requestedSessionID, admission.failureRoute
179
180 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
181 defer cancel()
182 var target serveSessionEntry
183 if sessionPath != "" {
184 target = serveSessionEntry{Name: strings.TrimSpace(name), Path: strings.TrimSpace(sessionPath), Title: strings.TrimSpace(sessionTitle)}
185 } else if requestedSessionID != "" {
186 target = serveSessionEntry{Name: strings.TrimSpace(name), SessionID: requestedSessionID, Title: strings.TrimSpace(sessionTitle)}
187 } else {
188 entries, err := serveSessions(ctx, client, base)
189 if err != nil {
190 return a.completeRemoteTabResumeFailure(tabID, tab, client, gen, failureRoute, fmt.Sprintf("Could not open remote session %q: %v", name, err))
191 }
192 for _, entry := range entries {
193 if entry.Name == name {
194 target = entry
195 break
196 }
197 }
198 }
199 if targetRoute := remoteSessionRoute(target); targetRoute != "" {
200 body, err := remoteSessionResumeBody(target)
201 if err != nil {
202 return a.completeRemoteTabResumeFailure(tabID, tab, client, gen, failureRoute, err.Error())
203 }
204 // /resume may reattach a controller already producing frames. Route them
205 // before the request returns so the all-session pump does not discard its
206 // handoff output or prompt replay as background work.
207 route := a.beginRemoteTabProvisionalResume(tabID, tab, client, gen, targetRoute)
208 route.previousSelection = previous
209 mounted, err := servePostSessionIdentityForSession(ctx, client, serveURL(base, "/resume"), body, "")
210 if err != nil {
211 var statusErr *serveHTTPStatusError
212 if errors.As(err, &statusErr) {
213 message := err.Error()
214 if remoteSessionTransitionBusy(err) {
215 message = "Finish the current turn before switching sessions."
216 }
217 return a.completeRemoteTabResumeFailure(tabID, tab, client, gen, route, message)
218 }
219 // A transport failure is ambiguous: Serve may have committed the
220 // resume before the tunnel lost its response. Query its current route
221 // before deciding whether to commit or restore local state.
222 reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), 3*time.Second)
223 current, reconcileErr := serveCurrentSession(reconcileCtx, client, base)
224 reconcileCancel()
225 if reconcileErr != nil || remoteSessionRoute(current) == "" {
226 // Do not publish either transcript from an unconfirmed generation.
227 // A fresh attach resolves Serve's current session before ready.
228 if startRetry := a.reconnectRemoteTabGeneration(tabID, gen); startRetry {
229 a.goRemoteTabSafe("remoteTabResumeReattach", func() { a.reattachRemoteTab(tabID) })
230 }
231 return true
232 }
233 if remoteSessionRoute(current) != targetRoute {
234 return a.reconcileRemoteTabRejectedResume(tabID, tab, client, gen, route, current, err)
235 }
236 if target.Name == "" {
237 target.Name = current.Name
238 }
239 if target.Title == "" {
240 target.Title = current.Title
241 }
242 target.Running = target.Running || current.Running
243 target.TakenOver = current.TakenOver
244 } else {
245 if mounted.SessionID != "" {
246 target.SessionID = mounted.SessionID
247 }
248 target.TakenOver = mounted.TakenOver || strings.TrimSpace(mounted.Path) != ""
249 }
250 title := strings.TrimSpace(target.Title)
251 if title == "" {
252 // An ID-only target carries no display text; stay labelled until
253 // the title refresh learns the generated one.
254 title = cmp.Or(name, remoteWorkspaceName(tab.ref.Workspace))
255 }
256 if !a.commitAndPublishRemoteTabResume(tabID, tab, client, gen, route, target, title) {
257 // A newer route won the publication fence; never restore the older
258 // selection over it. The spectator pin was for the losing route —
259 // drop it so the winning session renders as foreground.
260 a.clearRemoteTabSpectator(tabID, gen)
261 return true
262 }
263 a.goRemoteTabSafe("remoteTabResumeStatus", func() { _, _ = a.RemoteTabStatus(tabID) })
264 return true
265 }
266 return a.completeRemoteTabResumeFailure(tabID, tab, client, gen, failureRoute, fmt.Sprintf("remote session %q not found", name))
267 }
268
269 func (a *App) SetRemoteSessionPinned(hostID, workspace, name string, pinned bool) error {
270 return setRemoteSessionPinned(hostID, workspace, name, pinned)
271 }
272
273 func (a *App) SetRemoteProjectTitle(hostID, workspace, title string) error {
274 return editUserConfig(func(c *config.Config) error {
275 entry, ok := c.RemoteProject(hostID, workspace)
276 if !ok {
277 return fmt.Errorf("remote project %s:%s is not pinned", hostID, workspace)
278 }
279 entry.Title = strings.TrimSpace(title)
280 return c.UpsertRemoteProject(entry)
281 })
282 }
283
284 func (a *App) DeleteRemoteProjectSession(hostID, workspace, name string) error {
285 client, base, done, err := a.serveClientForRef(hostID, workspace)
286 if err != nil {
287 return err
288 }
289 defer done()
290 ctx, cancel := commandContext(a)
291 defer cancel()
292 // Keep the legacy basename for older Serve builds, but also send the
293 // immutable identity. Canonical sessions live under sessions-v4/<id>/ and
294 // cannot be removed by the old name.jsonl-only endpoint.
295 body, _ := json.Marshal(map[string]string{"name": name, "sessionId": strings.TrimSpace(name)})
296 return servePost(ctx, client, serveURL(base, "/delete-session"), body)
297 }
298
299 func (a *App) remoteTabPost(tabID, path string, body map[string]any) error {
300 if err := a.requireRemoteExecutionProtocol(tabID); err != nil {
301 return err
302 }
303 gated := path == "/goal/resume" || path == "/compact" || path == "/summarize"
304 for {
305 revision, admittedGen := "", uint64(0)
306 if gated {
307 var err error
308 revision, admittedGen, err = a.ensureRemoteModelSettings(tabID)
309 if err != nil {
310 return err
311 }
312 }
313 client, base, expectedPath, err := a.remoteTabCommandTarget(tabID)
314 if err != nil {
315 return err
316 }
317 if !a.remoteTabAdmissionCurrent(tabID, admittedGen) {
318 continue
319 }
320 ctx, cancel := commandContext(a)
321 var payload []byte
322 if body != nil {
323 payload, _ = json.Marshal(body)
324 }
325 err = servePostForSession(ctx, client, serveURL(base, path), payload, expectedPath, revision)
326 cancel()
327 return err
328 }
329 }
330
331 // remoteTabPostJSON posts a command through the same capability gate, session
332 // fence, and admission check as remoteTabPost, and additionally decodes the
333 // reply. Commands that return a new identity need the body; remoteTabPost
334 // discards it.
335 func (a *App) remoteTabPostJSON(tabID, path string, body map[string]any, out any) error {
336 if err := a.requireRemoteExecutionProtocol(tabID); err != nil {
337 return err
338 }
339 client, base, expectedPath, err := a.remoteTabCommandTarget(tabID)
340 if err != nil {
341 return err
342 }
343 ctx, cancel := commandContext(a)
344 defer cancel()
345 payload, err := json.Marshal(body)
346 if err != nil {
347 return err
348 }
349 url := serveURL(base, path)
350 resp, err := serveDoForSession(ctx, client, http.MethodPost, url, payload, expectedPath)
351 if err != nil {
352 return err
353 }
354 defer resp.Body.Close()
355 data, _ := io.ReadAll(io.LimitReader(resp.Body, serveSnapshotMaxBytes+1))
356 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
357 return &serveHTTPStatusError{url: url, statusCode: resp.StatusCode, message: strings.TrimSpace(string(data))}
358 }
359 if err := json.Unmarshal(data, out); err != nil {
360 return fmt.Errorf("decode %s: %w", path, err)
361 }
362 return nil
363 }
364
365 func (a *App) remoteTabGet(tabID, path string) (json.RawMessage, error) {
366 client, base, err := a.remoteTabCommandClient(tabID)
367 if err != nil {
368 return nil, err
369 }
370 ctx, cancel := commandContext(a)
371 defer cancel()
372 return serveGet(ctx, client, serveURL(base, path))
373 }
374
375 func (a *App) SetRemoteTabModel(tabID, ref string) error {
376 ref = strings.TrimSpace(ref)
377 if ref == "" {
378 return nil
379 }
380 a.remoteTabModelMu.Lock()
381 defer a.remoteTabModelMu.Unlock()
382 a.remoteTabMu.Lock()
383 tab := a.remoteTabs[tabID]
384 if tab == nil {
385 a.remoteTabMu.Unlock()
386 return fmt.Errorf("remote tab %q is not connected", tabID)
387 }
388 hostID := tab.ref.HostID
389 workspace := tab.ref.Workspace
390 currentModel := tab.model
391 expectedPath := tab.routing.currentPath
392 expectedGen := tab.gen
393 client, base := tab.client, tab.base
394 usable := client != nil && tab.state == "ready"
395 a.remoteTabMu.Unlock()
396 localProxy := a.remoteTabLocalProxy(tabID)
397
398 next := ref
399 if localProxy {
400 cfg, err := config.Load()
401 if err != nil {
402 return err
403 }
404 if strings.TrimSpace(currentModel) == "" {
405 currentModel = strings.TrimSpace(cfg.DefaultModel)
406 }
407 entry, ok := cfg.ResolveModel(ref)
408 if !ok {
409 return fmt.Errorf("unknown model %q", ref)
410 }
411 if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, entry.Name) {
412 return fmt.Errorf("model %q is not available", ref)
413 }
414 canonical := entry.Name + "/" + entry.Model
415 if _, err := resolveProxyProvider(cfg, canonical); err != nil {
416 return err
417 }
418 if !usable {
419 return fmt.Errorf("remote tab %q is not connected", tabID)
420 }
421 rt, err := a.remoteRT()
422 if err != nil {
423 return err
424 }
425 ctx, cancel := commandContext(a)
426 defer cancel()
427 if err := rt.SwitchCredentialProxyModel(ctx, hostID, workspace, currentModel, canonical, expectedPath); err != nil {
428 return err
429 }
430 next = canonical
431 } else {
432 if !usable {
433 return fmt.Errorf("remote tab %q is not connected", tabID)
434 }
435 payload, _ := json.Marshal(map[string]any{"ref": ref})
436 ctx, cancel := commandContext(a)
437 defer cancel()
438 if err := servePostForSession(ctx, client, serveURL(base, "/model"), payload, expectedPath); err != nil {
439 return err
440 }
441 }
442
443 tab.routeEventMu.Lock()
444 defer tab.routeEventMu.Unlock()
445 a.remoteTabMu.Lock()
446 current := a.remoteTabs[tabID]
447 if current != tab || current.client != client || current.gen != expectedGen {
448 a.remoteTabMu.Unlock()
449 return fmt.Errorf("remote tab %q closed while switching model", tabID)
450 }
451 if current.routing.currentPath != expectedPath {
452 // The fenced request changed the session that was visible when it began,
453 // but another client has since promoted a newer foreground route. Do not
454 // label that newer session with the older session's model response.
455 a.remoteTabMu.Unlock()
456 return nil
457 }
458 current.model = next
459 current.modelSeq = remoteTabModelSeq.Add(1)
460 meta := remoteTabMetaLocked(current)
461 a.remoteTabMu.Unlock()
462 a.saveTabsFromRemote()
463 a.emitRemoteEvent("remote-tab:updated", meta)
464 return nil
465 }
466
467 func (a *App) remoteTabLocalProxy(tabID string) bool {
468 hostID, ok := a.remoteTabHostID(tabID)
469 if !ok {
470 return false
471 }
472 cfg, err := config.Load()
473 if err != nil {
474 return false
475 }
476 host, ok := cfg.RemoteHost(hostID)
477 return ok && host.CredentialProxyEnabled()
478 }
479
480 func (a *App) remoteTabHostID(tabID string) (string, bool) {
481 a.remoteTabMu.Lock()
482 defer a.remoteTabMu.Unlock()
483 if tab := a.remoteTabs[tabID]; tab != nil {
484 return tab.ref.HostID, true
485 }
486 return "", false
487 }
488
489 func (a *App) remoteServeModelsForTab(tabID, current string) ([]ModelInfo, error) {
490 raw, err := a.remoteTabGet(tabID, "/models")
491 if err != nil {
492 return nil, err
493 }
494 var payload struct {
495 Models []struct {
496 Ref string `json:"ref"`
497 Provider string `json:"provider"`
498 Model string `json:"model"`
499 Active bool `json:"active"`
500 } `json:"models"`
501 }
502 if err := json.Unmarshal(raw, &payload); err != nil {
503 return nil, err
504 }
505 cur := strings.TrimSpace(current)
506 out := make([]ModelInfo, 0, len(payload.Models))
507 for _, entry := range payload.Models {
508 ref := strings.TrimSpace(entry.Ref)
509 if ref == "" {
510 continue
511 }
512 out = append(out, ModelInfo{Ref: ref, Provider: entry.Provider, Model: entry.Model, Current: ref == cur || entry.Active})
513 }
514 return out, nil
515 }
516
517 func (a *App) SetRemoteTabEffort(tabID, level string) error {
518 return a.remoteTabPost(tabID, "/effort", map[string]any{"level": level})
519 }
520
521 func (a *App) PauseRemoteTabGoal(tabID string) error {
522 if err := a.requireRemoteGoalLifecycle(tabID); err != nil {
523 return err
524 }
525 return a.remoteTabPost(tabID, "/goal/pause", nil)
526 }
527
528 func (a *App) ResumeRemoteTabGoal(tabID string) error {
529 if err := a.requireRemoteGoalLifecycle(tabID); err != nil {
530 return err
531 }
532 return a.remoteTabPost(tabID, "/goal/resume", nil)
533 }
534
535 func (a *App) CancelRemoteTabJobs(tabID string, jobIDs []string) error {
536 return a.remoteTabPost(tabID, "/jobs/cancel", map[string]any{"ids": jobIDs})
537 }
538
539 func (a *App) SteerRemoteTab(tabID, input string) error {
540 input = strings.TrimSpace(input)
541 if input == "" {
542 return fmt.Errorf("guidance is required")
543 }
544 return a.remoteTabPost(tabID, "/inbox/items", map[string]any{"input": input, "intent": "steer"})
545 }
546
547 func (a *App) SetRemoteTabPlanMode(tabID string, on bool) error {
548 return a.remoteTabPost(tabID, "/plan", map[string]any{"on": on})
549 }
550
551 func (a *App) CompactRemoteTab(tabID, instructions string) error {
552 return a.remoteTabPost(tabID, "/compact", map[string]any{"instructions": strings.TrimSpace(instructions)})
553 }
554
555 func (a *App) ReplayRemoteTabPrompts(tabID string) (json.RawMessage, error) {
556 return a.remoteTabGet(tabID, "/pending-prompts")
557 }
558
559 func (a *App) ForkRemoteTab(tabID string, turn int, name string) error {
560 return a.remoteTabPost(tabID, "/fork", map[string]any{"turn": turn, "name": name})
561 }
562
563 func (a *App) SummarizeRemoteTab(tabID string, turn int, mode string) error {
564 return a.remoteTabPost(tabID, "/summarize", map[string]any{"turn": turn, "mode": mode})
565 }
566
567 func (a *App) ForgetRemoteTab(tabID, name string) error {
568 return a.remoteTabPost(tabID, "/forget", map[string]any{"name": name})
569 }
570
571 func (a *App) RemoteTabBranches(tabID string) (json.RawMessage, error) {
572 return a.remoteTabGet(tabID, "/branches")
573 }
574
575 func (a *App) RemoteTabSkills(tabID string) (json.RawMessage, error) {
576 return a.remoteTabGet(tabID, "/skills")
577 }
578
579 func (a *App) refreshRemoteTabTitle(tabID string) {
580 a.remoteTabMu.Lock()
581 tab := a.remoteTabs[tabID]
582 if tab == nil || tab.client == nil {
583 a.remoteTabMu.Unlock()
584 return
585 }
586 client, base, gen, expectedPath := tab.client, tab.base, tab.gen, tab.routing.currentPath
587 refreshPath := expectedPath
588 if refreshPath == "" {
589 // An unsaved blank session has no path until its first turn materializes.
590 // NUL cannot occur in a real path, so it safely keys that in-flight lookup.
591 refreshPath = "\x00"
592 }
593 if tab.titleRefresh.path == refreshPath {
594 a.remoteTabMu.Unlock()
595 return
596 }
597 tab.titleRefresh.seq++
598 refreshSeq := tab.titleRefresh.seq
599 tab.titleRefresh.path = refreshPath
600 a.remoteTabMu.Unlock()
601 defer func() {
602 a.remoteTabMu.Lock()
603 if current := a.remoteTabs[tabID]; current == tab && current.titleRefresh.seq == refreshSeq {
604 current.titleRefresh.path = ""
605 }
606 a.remoteTabMu.Unlock()
607 }()
608
609 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
610 defer cancel()
611 entries, err := serveSessions(ctx, client, base)
612 if err != nil {
613 return
614 }
615 for _, entry := range entries {
616 entryRoute := remoteSessionIdentityRoute(strings.TrimSpace(entry.Path), strings.TrimSpace(entry.SessionID))
617 if !entry.Current || expectedPath != "" && entryRoute != strings.TrimSpace(expectedPath) {
618 continue
619 }
620 entry.Name = strings.TrimSpace(entry.Name)
621 entry.Path = strings.TrimSpace(entry.Path)
622 if !a.adoptRemoteTabTitleListing(tabID, tab, client, gen, expectedPath, entry) {
623 return
624 }
625
626 override, migrateErr := migrateRemoteSessionTitleOverride(tab.ref.HostID, tab.ref.Workspace, entry.Name)
627 if migrateErr != nil || override == "" {
628 override = remoteSessionTitleOverride(tab.ref.HostID, tab.ref.Workspace, entry.Name)
629 }
630 if override != "" {
631 a.applyRemoteTabTitleOverride(tabID, tab, client, gen, entry, override)
632 }
633 return
634 }
635 }
636
637 func (a *App) adoptRemoteTabTitleListing(tabID string, tab *remoteTab, client *http.Client, gen uint64, expectedPath string, entry serveSessionEntry) bool {
638 tab.routeEventMu.Lock()
639 defer tab.routeEventMu.Unlock()
640 a.remoteTabMu.Lock()
641 current := a.remoteTabs[tabID]
642 if current != tab || current.client != client || current.gen != gen || current.routing.currentPath != expectedPath {
643 a.remoteTabMu.Unlock()
644 return false
645 }
646 // Linearize the durable identity before migrating the synthetic blank's
647 // preferences. Preference I/O runs later without either application lock.
648 title := strings.TrimSpace(entry.Title)
649 changed := title != "" && current.topicTitle != title
650 identityChanged := current.session.name != entry.Name || current.session.path != entry.Path || current.session.sessionID != entry.SessionID || current.session.reset || current.session.newSession
651 if changed {
652 current.topicTitle = title
653 }
654 current.session.reset = false
655 current.session.newSession = false
656 current.session.name = entry.Name
657 current.session.path = entry.Path
658 current.session.sessionID = entry.SessionID
659 route := remoteSessionRoute(entry)
660 if current.routing.currentPath != route {
661 current.routing.currentPath = route
662 current.routing.pathRevision++
663 current.routing.revision++
664 }
665 meta := remoteTabMetaLocked(current)
666 a.remoteTabMu.Unlock()
667 if changed || identityChanged {
668 a.emitRemoteEvent("remote-tab:updated", meta)
669 a.saveTabsFromRemote()
670 }
671 return true
672 }
673
674 func (a *App) applyRemoteTabTitleOverride(tabID string, tab *remoteTab, client *http.Client, gen uint64, entry serveSessionEntry, override string) {
675 tab.routeEventMu.Lock()
676 defer tab.routeEventMu.Unlock()
677 a.remoteTabMu.Lock()
678 current := a.remoteTabs[tabID]
679 if current != tab || current.client != client || current.gen != gen ||
680 current.session.name != entry.Name || current.session.path != entry.Path || current.session.sessionID != entry.SessionID ||
681 current.routing.currentPath != remoteSessionRoute(entry) {
682 a.remoteTabMu.Unlock()
683 return
684 }
685 changed := current.topicTitle != override
686 if changed {
687 current.topicTitle = override
688 }
689 meta := remoteTabMetaLocked(current)
690 a.remoteTabMu.Unlock()
691 if changed {
692 a.emitRemoteEvent("remote-tab:updated", meta)
693 a.saveTabsFromRemote()
694 }
695 }
696
697 func (a *App) resetRemoteTabSession(tabID string) error {
698 return a.rotateRemoteTabSession(tabID, "/new")
699 }
700
701 // ClearRemoteTabSession clears the active remote transcript through Serve's
702 // dedicated session-rotation endpoint. It intentionally bypasses /submit so
703 // the frontend does not create an optimistic conversational turn for /clear.
704 func (a *App) ClearRemoteTabSession(tabID string) error {
705 return a.rotateRemoteTabSession(tabID, "/clear")
706 }
707
708 func (a *App) rotateRemoteTabSession(tabID, path string) error {
709 a.remoteTabMu.Lock()
710 tab := a.remoteTabs[tabID]
711 if tab == nil {
712 a.remoteTabMu.Unlock()
713 return fmt.Errorf("remote tab %q is not connected", tabID)
714 }
715 a.remoteTabMu.Unlock()
716 tab.sessionMu.Lock()
717 defer tab.sessionMu.Unlock()
718 a.remoteTabMu.Lock()
719 if a.remoteTabs[tabID] != tab {
720 a.remoteTabMu.Unlock()
721 return fmt.Errorf("remote tab %q closed while starting a new session", tabID)
722 }
723 if tab.client == nil {
724 if path != "/new" {
725 a.remoteTabMu.Unlock()
726 return fmt.Errorf("remote tab %q is not connected", tabID)
727 }
728 tab.session.newSession = true
729 tab.session.name = ""
730 tab.session.path = ""
731 if tab.routing.currentPath != "" {
732 tab.routing.currentPath = ""
733 tab.routing.pathRevision++
734 }
735 tab.routing.revision++
736 a.remoteTabMu.Unlock()
737 return nil
738 }
739 client, base := tab.client, tab.base
740 requestPath := tab.routing.currentPath
741 requestPathRevision := tab.routing.pathRevision
742 a.remoteTabMu.Unlock()
743
744 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
745 defer cancel()
746 identity, err := servePostSessionIdentityForSession(ctx, client, serveURL(base, path), nil, requestPath)
747 if err != nil {
748 // Session rotation can be rejected while the current remote turn is active.
749 // That does not invalidate the attached session or its event pump, so
750 // return an action error while leaving the tab ready and observable.
751 return err
752 }
753 target := serveSessionEntry{Path: identity.Path, SessionID: identity.SessionID, Current: true}
754 targetRoute := remoteSessionRoute(target)
755 title := a.localizedDefaultTopicTitle()
756 tab.routeEventMu.Lock()
757 defer tab.routeEventMu.Unlock()
758 a.remoteTabMu.Lock()
759 if a.remoteTabs[tabID] != tab || tab.client != client {
760 a.remoteTabMu.Unlock()
761 return fmt.Errorf("remote tab %q changed while starting a new session", tabID)
762 }
763 if tab.routing.pathRevision != requestPathRevision || tab.routing.currentPath != requestPath {
764 alreadyAdopted := tab.routing.currentPath == targetRoute
765 a.remoteTabMu.Unlock()
766 if alreadyAdopted {
767 a.saveTabsFromRemote()
768 }
769 // A session_changed frame won the race. Its foreground identity is newer
770 // than this HTTP response, even when a second rotation already moved on.
771 return nil
772 }
773 tab.topicTitle = title
774 tab.session.reset = true
775 tab.session.newSession = true
776 tab.session.name = target.Name
777 tab.session.path = target.Path
778 tab.session.sessionID = target.SessionID
779 tab.routing.currentPath = targetRoute
780 tab.routing.pathRevision++
781 tab.routing.revision++
782 tab.pendingEvents = nil
783 tab.runtime.revision++
784 tab.runtime.running = false
785 tab.runtime.turnStartedAt = 0
786 tab.runtime.pendingPrompt = false
787 tab.runtime.backgroundJobs = 0
788 tab.runtime.cancelRequested = false
789 tab.runtime.cancellable = false
790 meta := remoteTabMetaLocked(tab)
791 a.remoteTabMu.Unlock()
792 a.emitRemoteEvent("remote-tab:updated", meta)
793 a.saveTabsFromRemote()
794 a.emitRemoteTabStateLocked(tab, "ready", "")
795 return nil
796 }
797
797 lines GO