@@ -52,6 +52,9 @@ func (s *Server) loadUserConfig(r *http.Request) (*config.Config, error) {
5252 return nil , err
5353 }
5454 }
55+ if err := scope .SettingInto (r .Context (), s .dataStore , scope .PrefsNamespace , uid , "" , & cfg .Prefs ); err != nil {
56+ return nil , err
57+ }
5558 if provs , err := scope .Providers (r .Context (), s .dataStore , uid , "" ); err == nil {
5659 for k , v := range provs {
5760 cfg .Providers [k ] = v
@@ -197,7 +200,7 @@ var settingNamespaces = []settingNamespace{
197200 dst : func (c * config.Config ) interface {} { return & c .Teams },
198201 collect : func (c * config.Config ) map [string ]interface {} { return wrapKeyed (c .Teams ) }},
199202 {namespace : "bindings" ,
200- dst : func (c * config.Config ) interface {} { return & c .Bindings },
203+ dst : func (c * config.Config ) interface {} { return & c .Bindings },
201204 collect : func (c * config.Config ) map [string ]interface {} {
202205 if len (c .Bindings ) == 0 {
203206 return nil
@@ -506,6 +509,7 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
506509 if s .dataStore != nil {
507510 _ = scope .SettingInto (r .Context (), s .dataStore , "agents.defaults" , "" , "" , & sysDefaults )
508511 }
512+ serverTimezone := time .Local .String ()
509513 // Marshal-then-extend keeps the response shape compatible (existing
510514 // callers ignore the extra `meta` key) without forcing a refactor of
511515 // config.Config to carry presentation metadata.
@@ -514,6 +518,7 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
514518 _ = json .Unmarshal (blob , & out )
515519 out ["meta" ] = map [string ]any {
516520 "systemDefaultModel" : sysDefaults .Model ,
521+ "serverTimezone" : serverTimezone ,
517522 }
518523 jsonResponse (w , http .StatusOK , out )
519524}
@@ -534,6 +539,22 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
534539 jsonResponse (w , http .StatusBadRequest , map [string ]any {"ok" : false , "error" : err .Error ()})
535540 return
536541 }
542+ var raw struct {
543+ Prefs * config.PrefsCfg `json:"prefs"`
544+ Skills * struct {
545+ AgentEntries map [string ]map [string ]config.SkillEntryCfg `json:"agentEntries"`
546+ } `json:"skills"`
547+ }
548+ _ = json .Unmarshal (buf , & raw )
549+ if raw .Prefs != nil {
550+ raw .Prefs .Timezone = strings .TrimSpace (raw .Prefs .Timezone )
551+ if raw .Prefs .Timezone != "" {
552+ if _ , err := time .LoadLocation (raw .Prefs .Timezone ); err != nil {
553+ jsonResponse (w , http .StatusBadRequest , map [string ]any {"ok" : false , "error" : "invalid timezone: use an IANA name like Asia/Shanghai" })
554+ return
555+ }
556+ }
557+ }
537558 merged , err := s .loadUserConfig (r )
538559 if err != nil {
539560 jsonResponse (w , http .StatusInternalServerError , map [string ]any {"ok" : false , "error" : err .Error ()})
@@ -556,12 +577,18 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
556577 // name=skills.entries). Pull from the raw body — not from the
557578 // merged Config — so we only touch agents the caller actually
558579 // patched, and don't echo every existing override back as a write.
559- var raw struct {
560- Skills * struct {
561- AgentEntries map [string ]map [string ]config.SkillEntryCfg `json:"agentEntries"`
562- } `json:"skills"`
580+ if raw .Prefs != nil {
581+ sc , scopeID := s .scopeForSave (r )
582+ uid , aid := scope .OwnershipFromScope (sc , scopeID )
583+ data := map [string ]interface {}{}
584+ if raw .Prefs .Timezone != "" {
585+ data ["timezone" ] = raw .Prefs .Timezone
586+ }
587+ if err := scope .SaveSetting (r .Context (), s .dataStore , uid , aid , scope .PrefsNamespace , data ); err != nil {
588+ jsonResponse (w , http .StatusInternalServerError , map [string ]any {"ok" : false , "error" : err .Error ()})
589+ return
590+ }
563591 }
564- _ = json .Unmarshal (buf , & raw )
565592 if raw .Skills != nil && raw .Skills .AgentEntries != nil {
566593 for agentID , entries := range raw .Skills .AgentEntries {
567594 rec , err := s .dataStore .GetAgent (r .Context (), agentID )
@@ -810,15 +837,15 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
810837// --- chat handlers (delegate to per-user agent) ---
811838
812839type chatRequest struct {
813- AgentID string `json:"agentId,omitempty"`
814- SessionID string `json:"sessionId"`
840+ AgentID string `json:"agentId,omitempty"`
841+ SessionID string `json:"sessionId"`
815842 // ProjectID, when non-empty AND the session row doesn't yet exist,
816843 // is the "this chat belongs to project X" hint the URL carries
817844 // (`?project=<pid>`) before the first message. Once the row exists
818845 // it's authoritative — the server reads project_id from the row
819846 // and ignores any later hint.
820- ProjectID string `json:"projectId,omitempty"`
821- Message string `json:"message"`
847+ ProjectID string `json:"projectId,omitempty"`
848+ Message string `json:"message"`
822849 // Images carries data URLs / HTTPS URLs for image attachments. The
823850 // web client historically sends them under `imageUrls` (camelCase)
824851 // while the API path uses `images`; we accept both and merge below
@@ -1073,6 +1100,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
10731100 defer keepalive .Stop ()
10741101
10751102 clientGone := r .Context ().Done ()
1103+ forwardedAny := false
10761104 // turnPending flips on when the slash handler reports it queued a
10771105 // continuation via bus.Inbound (`turn_pending` event). The POST
10781106 // goroutine's HandleMessage has already returned, but the real
@@ -1109,9 +1137,12 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
11091137 continue
11101138 }
11111139 if env .Event .Type == "done" {
1140+ forwardEvent (w , flusher , env )
1141+ forwardedAny = true
11121142 return
11131143 }
11141144 forwardEvent (w , flusher , env )
1145+ forwardedAny = true
11151146 default :
11161147 break drain
11171148 }
@@ -1124,6 +1155,12 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
11241155 agentDone = nil
11251156 continue
11261157 }
1158+ if ! forwardedAny {
1159+ forwardSyntheticEvent (w , flusher , agent.ChatEvent {
1160+ Type : "error" ,
1161+ Data : map [string ]any {"message" : "agent finished without emitting a response" },
1162+ })
1163+ }
11271164 return
11281165 case <- agentCtx .Done ():
11291166 // Safety net for the turnPending path above: bail out at
@@ -1142,6 +1179,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
11421179 continue
11431180 }
11441181 forwardEvent (w , flusher , env )
1182+ forwardedAny = true
11451183 if env .Event .Type == "done" {
11461184 return
11471185 }
@@ -1170,24 +1208,28 @@ func forwardEvent(w http.ResponseWriter, flusher http.Flusher, env agent.EventEn
11701208 flusher .Flush ()
11711209}
11721210
1211+ func forwardSyntheticEvent (w http.ResponseWriter , flusher http.Flusher , evt agent.ChatEvent ) {
1212+ forwardEvent (w , flusher , agent.EventEnvelope {Seq : - 1 , Event : evt })
1213+ }
1214+
11731215// handleChatSubscribe holds an SSE connection open for one (agent,
11741216// session) pair and forwards three kinds of traffic:
11751217//
1176- // 1. Replay: session_events rows with seq > since (or > Last-Event-ID)
1177- // that the client missed before connecting. Lets a freshly
1178- // reloaded page pick up an in-flight turn without the rest of the
1179- // reply disappearing.
1218+ // 1. Replay: session_events rows with seq > since (or > Last-Event-ID)
1219+ // that the client missed before connecting. Lets a freshly
1220+ // reloaded page pick up an in-flight turn without the rest of the
1221+ // reply disappearing.
11801222//
1181- // 2. Live agent chat events from the hub — every emitEvent call from
1182- // the agent loop fans through here. This covers both the
1183- // synchronous POST /api/chat/stream path AND turns started by
1184- // other tabs / cron firings, so any open chat panel sees them
1185- // regardless of who triggered the work.
1223+ // 2. Live agent chat events from the hub — every emitEvent call from
1224+ // the agent loop fans through here. This covers both the
1225+ // synchronous POST /api/chat/stream path AND turns started by
1226+ // other tabs / cron firings, so any open chat panel sees them
1227+ // regardless of who triggered the work.
11861228//
1187- // 3. Legacy WebChannel bus messages — cron-fired final replies that
1188- // route through bus.Outbound rather than the chat-event path.
1189- // Kept so we don't lose pre-existing functionality during the
1190- // transition.
1229+ // 3. Legacy WebChannel bus messages — cron-fired final replies that
1230+ // route through bus.Outbound rather than the chat-event path.
1231+ // Kept so we don't lose pre-existing functionality during the
1232+ // transition.
11911233//
11921234// Auth gating reuses resolveAgent, so the caller must already have
11931235// permission to chat with this agent. The subscription doesn't
@@ -1431,9 +1473,9 @@ func (s *Server) readWorkspaceFileBytes(ctx context.Context, agentID, relPath st
14311473// parseTodoMarkdown extracts checkbox lines from a todo.md body and
14321474// returns them as structured items. Conventions:
14331475//
1434- // - [ ] text → pending
1435- // - [x] text → completed
1436- // - [X] text → completed (case-insensitive)
1476+ // - [ ] text → pending
1477+ // - [x] text → completed
1478+ // - [X] text → completed (case-insensitive)
14371479//
14381480// Anything else (heading lines, blank lines, non-checkbox bullets) is
14391481// ignored — todo.md doubles as a human-readable plan document, so we
@@ -1647,10 +1689,10 @@ func (s *Server) handleChats(w http.ResponseWriter, r *http.Request) {
16471689 }
16481690 totalPages := (total + pageSize - 1 ) / pageSize
16491691 jsonResponse (w , http .StatusOK , map [string ]any {
1650- "sessions" : out ,
1651- "page" : page ,
1652- "pageSize" : pageSize ,
1653- "total" : total ,
1692+ "sessions" : out ,
1693+ "page" : page ,
1694+ "pageSize" : pageSize ,
1695+ "total" : total ,
16541696 "totalPages" : totalPages ,
16551697 })
16561698}
0 commit comments