Refactor: use user locale for time displays

Add user-configurable timezone and 12/24-hour preferences, then wire them through settings, runtime snapshots, scheduler payloads, wait handling, notifications, backups, memory, plugin metadata, and frontend formatters. Keep UTC as the boundary for absolute instants while serializing user-facing dates in the configured or browser-resolved timezone. Preserve scheduler wall-clock inputs in the selected timezone, propagate TZ into desktop/runtime process environments, and restart active desktop sessions when the runtime timezone changes. Cover the risky paths with timezone regression tests for settings normalization, auto and fixed timezone resolution, scheduler round-trips, memory timestamp conversion, and desktop timezone sync.

Alessandro committed May 21, 2026 at 15:25 UTC d1827e6c660767bf05f4a360a6ad1afa931dbc15
65 files changed +1399 -342
agent.py
+4 -4
@@ -2,7 +2,7 @@ import asyncio, random, string, threading
2
3 from collections import OrderedDict
4 from dataclasses import dataclass, field
5 -from datetime import datetime, timezone
5 +from datetime import datetime
6 from typing import Any, Awaitable, Coroutine, Dict, Literal
7 from enum import Enum
8 import models
@@ -86,11 +86,11 @@ class AgentContext:
86 self.paused = paused
87 self.streaming_agent = streaming_agent
88 self.task: DeferredTask | None = None
89 - self.created_at = created_at or datetime.now(timezone.utc)
89 + self.created_at = created_at or Localization.get().now()
90 self.type = type
91 AgentContext._counter += 1
92 self.no = AgentContext._counter
93 - self.last_message = last_message or datetime.now(timezone.utc)
93 + self.last_message = last_message or Localization.get().now()
94
95 # initialize agent at last (context is complete now)
96 self.agent0 = agent0 or Agent(0, self.config, self)
@@ -666,7 +666,7 @@ class Agent:
666 def hist_add_message(
667 self, ai: bool, content: history.MessageContent, tokens: int = 0, id: str = ""
668 ):
669 - self.last_message = datetime.now(timezone.utc)
669 + self.last_message = Localization.get().now()
670 # Allow extensions to process content before adding to history
671 content_data = {"content": content}
672 extension.call_extensions_sync(
api/download_work_dir_files.py
+2 -2
@@ -1,5 +1,4 @@
1 import base64
2 -from datetime import datetime
2 from io import BytesIO
3 import os
4 from pathlib import Path
@@ -10,6 +9,7 @@ from flask import Response
9
10 from helpers.api import ApiHandler, Input, Output, Request
11 from helpers import files, runtime
12 +from helpers.localization import Localization
13 from api.download_work_dir_file import fetch_file, stream_file_download
14
15
@@ -65,7 +65,7 @@ def normalize_paths(paths) -> list[str]:
65
66
67 def selected_archive_name(count: int) -> str:
68 - stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
68 + stamp = Localization.get().now().strftime("%Y%m%d-%H%M%S")
69 return f"agent-zero-selected-{count}-{stamp}.zip"
70
71
api/notification_create.py
+1
@@ -57,6 +57,7 @@ class NotificationCreate(ApiHandler):
57 return {
58 "success": True,
59 "notification_id": notification.id,
60 + "notification": notification.output(),
61 "message": "Notification created successfully",
62 }
63
api/plugins.py
+2 -2
@@ -2,10 +2,10 @@ import json
2 import os
3 import subprocess
4 import sys
5 -from datetime import datetime, timezone
5
6 from helpers.api import ApiHandler, Request, Response
7 from helpers import plugins, files, extension
8 +from helpers.localization import Localization
9
10
11 class Plugins(ApiHandler):
@@ -287,7 +287,7 @@ class Plugins(ApiHandler):
287 if not files.exists(execute_script):
288 return Response(status=404, response="execute.py not found")
289
290 - executed_at = datetime.now(timezone.utc).isoformat()
290 + executed_at = Localization.get().now_iso()
291 try:
292 result = subprocess.run(
293 [sys.executable, execute_script],
api/settings_set.py
+5 -1
@@ -8,7 +8,11 @@ from typing import Any
8 class SetSettings(ApiHandler):
9 async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response:
10 frontend = input.get("settings", input)
11 + browser_timezone = input.get("browser_timezone")
12 backend = settings.convert_in(settings.Settings(**frontend))
12 - backend = settings.set_settings(backend)
13 + backend = settings.set_settings(
14 + backend,
15 + browser_timezone=browser_timezone if isinstance(browser_timezone, str) else None,
16 + )
17 out = settings.convert_out(backend)
18 return dict(out)
extensions/python/message_loop_prompts_after/_60_include_current_datetime.py
+1 -8
@@ -1,4 +1,3 @@
1 -from datetime import datetime, timezone
1 from helpers.extension import Extension
2 from agent import LoopData
3 from helpers.localization import Localization
@@ -9,13 +8,7 @@ class IncludeCurrentDatetime(Extension):
8 if not self.agent:
9 return
10
12 - # get current datetime
13 - current_datetime = Localization.get().utc_dt_to_localtime_str(
14 - datetime.now(timezone.utc), sep=" ", timespec="seconds"
15 - )
16 - # remove timezone offset
17 - if current_datetime and "+" in current_datetime:
18 - current_datetime = current_datetime.split("+")[0]
11 + current_datetime = Localization.get().now().strftime("%Y-%m-%d %H:%M:%S %Z")
12
13 # read prompt
14 datetime_prompt = self.agent.read_prompt(
extensions/python/user_message_ui/_10_update_check.py
+14 -8
@@ -2,6 +2,7 @@ from helpers import notification
2 from helpers.extension import Extension
3 from agent import LoopData
4 from helpers import files, settings, update_check
5 +from helpers.localization import Localization
6 import datetime
7 import json
8
@@ -11,14 +12,18 @@ import json
12 # do not check too often, use cooldown
13 # do not notify too often
14
14 -last_check = datetime.datetime.fromtimestamp(0)
15 +last_check = datetime.datetime.fromtimestamp(0, tz=Localization.get().get_tzinfo())
16 check_cooldown_seconds = 60
17 last_notification_id = None
17 -last_notification_time = datetime.datetime.fromtimestamp(0)
18 +last_notification_time = datetime.datetime.fromtimestamp(0, tz=Localization.get().get_tzinfo())
19 notification_cooldown_seconds = 60 * 60 * 24
20 notification_state_file = "usr/update-check-state.json"
21
22
23 +def _now() -> datetime.datetime:
24 + return Localization.get().now()
25 +
26 +
27 def _load_notification_state() -> dict:
28 try:
29 return json.loads(files.read_file(notification_state_file))
@@ -34,13 +39,13 @@ def _parse_timestamp(value: str | None) -> datetime.datetime | None:
39 except ValueError:
40 return None
41 if parsed.tzinfo:
37 - return parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
38 - return parsed
42 + return parsed.astimezone(Localization.get().get_tzinfo())
43 + return Localization.get().localize_naive_datetime(parsed)
44
45
46 def _remember_notification(notif: dict, now: datetime.datetime):
47 state = {
43 - "last_notification_at": now.replace(tzinfo=datetime.timezone.utc).isoformat(),
48 + "last_notification_at": now.isoformat(),
49 "last_notification_id": notif.get("id") or "",
50 "last_notification_group": notif.get("group", "update_check"),
51 }
@@ -62,16 +67,17 @@ class UpdateCheck(Extension):
67 return
68
69 # check if cooldown has passed
65 - if (datetime.datetime.now() - last_check).total_seconds() < check_cooldown_seconds:
70 + now = _now()
71 + if (now - last_check).total_seconds() < check_cooldown_seconds:
72 return
67 - last_check = datetime.datetime.now()
73 + last_check = now
74
75 # check for updates
76 version = await update_check.check_version()
77
78 # if the user should update, send notification
79 if notif := version.get("notification"):
74 - now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
80 + now = _now()
81 stored_state = _load_notification_state()
82 stored_notification_time = _parse_timestamp(stored_state.get("last_notification_at"))
83 effective_notification_time = stored_notification_time or last_notification_time
helpers/backup.py
+9 -5
@@ -9,6 +9,7 @@ from typing import List, Dict, Any, Optional
9 from pathspec import PathSpec
10
11 from helpers import files, runtime, git
12 +from helpers.localization import Localization
13 from helpers.print_style import PrintStyle
14
15
@@ -35,7 +36,7 @@ class BackupService:
36
37 def get_default_backup_metadata(self) -> Dict[str, Any]:
38 """Get default backup patterns and metadata"""
38 - timestamp = datetime.datetime.now().isoformat()
39 + timestamp = Localization.get().now_iso()
40
41 default_patterns = self._get_default_patterns()
42 include_patterns, exclude_patterns = self._parse_patterns(default_patterns)
@@ -144,7 +145,7 @@ class BackupService:
145 "home": os.environ.get("HOME", "unknown"),
146 "shell": os.environ.get("SHELL", "unknown"),
147 "path": os.environ.get("PATH", "")[:200] + "..." if len(os.environ.get("PATH", "")) > 200 else os.environ.get("PATH", ""),
147 - "timezone": str(datetime.datetime.now().astimezone().tzinfo),
148 + "timezone": Localization.get().get_timezone(),
149 "working_directory": os.getcwd(),
150 "agent_zero_root": files.get_abs_path(""),
151 "runtime_mode": "development" if runtime.is_development() else "production"
@@ -305,7 +306,10 @@ class BackupService:
306 "path": pattern_path,
307 "real_path": file_path,
308 "size": stat.st_size,
308 - "modified": datetime.datetime.fromtimestamp(stat.st_mtime).isoformat(),
309 + "modified": datetime.datetime.fromtimestamp(
310 + stat.st_mtime,
311 + tz=Localization.get().get_tzinfo(),
312 + ).isoformat(),
313 "type": "file"
314 })
315 processed_count += 1
@@ -356,7 +360,7 @@ class BackupService:
360 metadata = {
361 # Basic backup information
362 "agent_zero_version": self.agent_zero_version,
359 - "timestamp": datetime.datetime.now().isoformat(),
363 + "timestamp": Localization.get().now_iso(),
364 "backup_name": backup_name,
365 "include_hidden": include_hidden,
366
@@ -698,7 +702,7 @@ class BackupService:
702 })
703 continue
704 elif overwrite_policy == "backup":
701 - timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
705 + timestamp = Localization.get().now().strftime('%Y%m%d_%H%M%S')
706 backup_path = f"{target_path}.backup.{timestamp}"
707 import shutil
708 shutil.move(target_path, backup_path)
helpers/document_query.py
+2 -2
@@ -10,7 +10,6 @@ from langchain_unstructured import UnstructuredLoader # noqa E402
10
11 from urllib.parse import urlparse
12 from typing import Callable, Sequence, List, Optional, Tuple
13 -from datetime import datetime
13
14 from langchain_community.document_loaders.pdf import PyMuPDFLoader
15 from langchain_community.document_transformers import MarkdownifyTransformer
@@ -20,6 +19,7 @@ from langchain_core.documents import Document
19 from langchain.schema import SystemMessage, HumanMessage
20
21 from helpers.print_style import PrintStyle
22 +from helpers.localization import Localization
23 from helpers import files, errors
24 from helpers.network import HttpFetchResult, fetch_public_http_resource
25 from agent import Agent
@@ -120,7 +120,7 @@ class DocumentQueryStore:
120 # Initialize metadata
121 doc_metadata = metadata or {}
122 doc_metadata["document_uri"] = document_uri
123 - doc_metadata["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
123 + doc_metadata["timestamp"] = Localization.get().now_iso(timespec="seconds")
124
125 # Split text into chunks
126 text_splitter = RecursiveCharacterTextSplitter(
helpers/file_browser.py
+5 -1
@@ -8,6 +8,7 @@ from helpers.security import safe_filename
8 from datetime import datetime
9
10 from helpers import files
11 +from helpers.localization import Localization
12 from helpers.print_style import PrintStyle
13
14
@@ -260,7 +261,10 @@ class FileBrowser:
261 entry_data: Dict[str, Any] = {
262 "name": filename,
263 "path": str(entry_path.relative_to(self.base_dir)),
263 - "modified": datetime.fromtimestamp(stat_info.st_mtime).isoformat()
264 + "modified": datetime.fromtimestamp(
265 + stat_info.st_mtime,
266 + tz=Localization.get().get_tzinfo(),
267 + ).isoformat()
268 }
269
270 # Add symlink information if this is a symlink
helpers/file_tree.py
+15 -10
@@ -2,13 +2,14 @@ from __future__ import annotations
2
3 from collections import deque
4 from dataclasses import dataclass
5 -from datetime import datetime, timezone
5 +from datetime import datetime
6 import os
7 from typing import Any, Callable, Iterable, Literal, Optional, Sequence
8
9 from pathspec import PathSpec
10
11 from helpers import files as files_helper
12 +from helpers.localization import Localization
13
14 SORT_BY_NAME = "name"
15 SORT_BY_CREATED = "created"
@@ -22,6 +23,10 @@ OUTPUT_MODE_FLAT = "flat"
23 OUTPUT_MODE_NESTED = "nested"
24
25
26 +def _from_timestamp(timestamp: float) -> datetime:
27 + return datetime.fromtimestamp(timestamp, tz=Localization.get().get_tzinfo())
28 +
29 +
30 def file_tree(
31 relative_path: str,
32 *,
@@ -75,7 +80,7 @@ def file_tree(
80 while traversal and limit calculations remain breadth-first by depth. When ``max_lines`` is set, the number
81 of non-comment entries (excluding the root banner) never exceeds that limit; informational summary comments
82 are emitted in addition when necessary.
78 - * ``created`` and ``modified`` values in structured outputs are timezone-aware UTC
83 + * ``created`` and ``modified`` values in structured outputs are timezone-aware user-local
84 :class:`datetime.datetime` objects::
85
86 item = flat_items[0]
@@ -111,8 +116,8 @@ def file_tree(
116 name=root_name,
117 level=0,
118 item_type="folder",
114 - created=datetime.fromtimestamp(root_stat.st_ctime, tz=timezone.utc),
115 - modified=datetime.fromtimestamp(root_stat.st_mtime, tz=timezone.utc),
119 + created=_from_timestamp(root_stat.st_ctime),
120 + modified=_from_timestamp(root_stat.st_mtime),
121 parent=None,
122 items=[],
123 rel_path="",
@@ -132,8 +137,8 @@ def file_tree(
137 name=entry.name,
138 level=level,
139 item_type=item_type,
135 - created=datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc),
136 - modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
140 + created=_from_timestamp(stat.st_ctime),
141 + modified=_from_timestamp(stat.st_mtime),
142 parent=parent,
143 items=[] if item_type == "folder" else None,
144 rel_path=rel_posix,
@@ -413,8 +418,8 @@ def _create_folder_unprocessed_comment(
418 name=entry.name,
419 level=folder_node.level + 1,
420 item_type="folder",
416 - created=datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc),
417 - modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
421 + created=_from_timestamp(stat.st_ctime),
422 + modified=_from_timestamp(stat.st_mtime),
423 parent=folder_node,
424 items=None,
425 rel_path=os.path.join(folder_node.rel_path, entry.name),
@@ -427,8 +432,8 @@ def _create_folder_unprocessed_comment(
432 name=entry.name,
433 level=folder_node.level + 1,
434 item_type="file",
430 - created=datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc),
431 - modified=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
435 + created=_from_timestamp(stat.st_ctime),
436 + modified=_from_timestamp(stat.st_mtime),
437 parent=folder_node,
438 items=None,
439 rel_path=os.path.join(folder_node.rel_path, entry.name),
helpers/git.py
+9 -2
@@ -8,6 +8,7 @@ import base64
8 import re
9 from urllib.parse import urlparse, urlunparse
10 from helpers import files
11 +from helpers.localization import Localization
12
13
14 def strip_auth_from_url(url: str) -> str:
@@ -98,7 +99,10 @@ class GitRepoReleaseInfo:
99
100
101 def _format_git_timestamp(timestamp: int) -> str:
101 - return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
102 + return datetime.fromtimestamp(
103 + timestamp,
104 + tz=Localization.get().get_tzinfo(),
105 + ).strftime('%Y-%m-%d %H:%M:%S %Z')
106
107
108 def _split_describe_version(describe: str) -> tuple[str, int]:
@@ -517,7 +521,10 @@ def get_repo_status(repo_path: str) -> dict:
521 "hash": commit.hexsha[:7],
522 "message": str(commit.message).split("\n")[0][:80],
523 "author": str(commit.author),
520 - "date": datetime.fromtimestamp(commit.committed_date).strftime('%Y-%m-%d %H:%M')
524 + "date": datetime.fromtimestamp(
525 + commit.committed_date,
526 + tz=Localization.get().get_tzinfo(),
527 + ).strftime('%Y-%m-%d %H:%M %Z')
528 }
529 except Exception:
530 pass
helpers/localization.py
+103 -73
@@ -1,4 +1,6 @@
1 -from datetime import datetime, timezone as dt_timezone, timedelta
1 +from datetime import datetime, timezone as dt_timezone
2 +import os
3 +import time
4 import pytz # type: ignore
5
6 from helpers.print_style import PrintStyle
@@ -8,9 +10,9 @@ from helpers.dotenv import get_dotenv_value, save_dotenv_value
10
11 class Localization:
12 """
11 - Localization class for handling timezone conversions between UTC and local time.
12 - Now stores a fixed UTC offset (in minutes) derived from the provided timezone name
13 - to avoid noisy updates when equivalent timezones share the same offset.
13 + Localization class for handling timezone conversions around the user's IANA
14 + timezone. UTC is still used when an external protocol requires an absolute
15 + instant, but user-facing timestamps are formatted in the configured timezone.
16 """
17
18 # singleton
@@ -26,29 +28,38 @@ class Localization:
28 self.timezone: str = "UTC"
29 self._offset_minutes: int = 0
30 self._last_timezone_change: datetime | None = None
29 - # Load persisted values if available
30 - persisted_tz = str(get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC"))
31 + # Load persisted values if available.
32 + persisted_tz = str(get_dotenv_value("DEFAULT_USER_TIMEZONE", os.environ.get("TZ") or "UTC"))
33 persisted_offset = get_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", None)
34 if timezone is not None:
35 # Explicit override
36 self.set_timezone(timezone)
37 else:
38 # Initialize from persisted values
37 - self.timezone = persisted_tz
38 - if persisted_offset is not None:
39 - try:
40 - self._offset_minutes = int(str(persisted_offset))
41 - except Exception:
42 - self._offset_minutes = self._compute_offset_minutes(self.timezone)
43 - save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes))
44 - else:
45 - # Compute from timezone and persist
46 - self._offset_minutes = self._compute_offset_minutes(self.timezone)
39 + try:
40 + pytz.timezone(persisted_tz)
41 + self.timezone = persisted_tz
42 + except pytz.exceptions.UnknownTimeZoneError:
43 + self.timezone = "UTC"
44 + current_offset = self._compute_offset_minutes(self.timezone)
45 + try:
46 + persisted_offset_minutes = int(str(persisted_offset)) if persisted_offset is not None else None
47 + except Exception:
48 + persisted_offset_minutes = None
49 + self._offset_minutes = current_offset
50 + if persisted_offset_minutes != current_offset:
51 save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes))
52 + self.apply_process_timezone()
53
54 def get_timezone(self) -> str:
55 return self.timezone
56
57 + def get_tzinfo(self):
58 + try:
59 + return pytz.timezone(self.timezone)
60 + except pytz.exceptions.UnknownTimeZoneError:
61 + return pytz.timezone("UTC")
62 +
63 def _compute_offset_minutes(self, timezone_name: str) -> int:
64 tzinfo = pytz.timezone(timezone_name)
65 now_in_tz = datetime.now(tzinfo)
@@ -58,76 +69,97 @@ class Localization:
69 def get_offset_minutes(self) -> int:
70 return self._offset_minutes
71
61 - def _can_change_timezone(self) -> bool:
62 - """Check if timezone can be changed (rate limited to once per hour)."""
63 - if self._last_timezone_change is None:
64 - return True
65 -
66 - time_diff = datetime.now() - self._last_timezone_change
67 - return time_diff >= timedelta(hours=1)
72 + def apply_process_timezone(self) -> None:
73 + """Apply the configured timezone to this process and child processes."""
74 + os.environ["TZ"] = self.timezone
75 + if hasattr(time, "tzset"):
76 + try:
77 + time.tzset()
78 + except Exception as e:
79 + PrintStyle.error(f"Error applying timezone {self.timezone}: {e}")
80 +
81 + def now(self) -> datetime:
82 + """Return the current datetime in the user's configured timezone."""
83 + return datetime.now(self.get_tzinfo())
84 +
85 + def now_iso(self, sep: str = "T", timespec: str = "auto") -> str:
86 + return self.now().isoformat(sep=sep, timespec=timespec)
87 +
88 + def localize_naive_datetime(self, dt: datetime) -> datetime:
89 + """Treat a naive datetime as user-local and make it timezone-aware."""
90 + if dt.tzinfo is not None:
91 + return dt
92 + tzinfo = self.get_tzinfo()
93 + try:
94 + return tzinfo.localize(dt, is_dst=None)
95 + except pytz.exceptions.AmbiguousTimeError:
96 + return tzinfo.localize(dt, is_dst=False)
97 + except pytz.exceptions.NonExistentTimeError:
98 + return tzinfo.localize(dt, is_dst=True)
99
100 def set_timezone(self, timezone: str) -> None:
70 - """Set the timezone name, but internally store and compare by UTC offset minutes."""
101 + """Set the user's IANA timezone and propagate it to child processes."""
102 try:
103 # Validate timezone and compute its current offset
104 _ = pytz.timezone(timezone)
105 new_offset = self._compute_offset_minutes(timezone)
75 -
76 - # If offset changes, check rate limit and update
77 - if new_offset != getattr(self, "_offset_minutes", None):
78 - if not self._can_change_timezone():
79 - return
80 -
81 - prev_tz = getattr(self, "timezone", "None")
82 - prev_off = getattr(self, "_offset_minutes", None)
83 - PrintStyle.debug(
84 - f"Changing timezone from {prev_tz} (offset {prev_off}) to {timezone} (offset {new_offset})"
85 - )
86 - self._offset_minutes = new_offset
87 - self.timezone = timezone
88 - # Persist both the human-readable tz and the numeric offset
89 - save_dotenv_value("DEFAULT_USER_TIMEZONE", timezone)
90 - save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes))
91 -
92 - # Update rate limit timestamp only when actual change occurs
93 - self._last_timezone_change = datetime.now()
94 - else:
95 - # Offset unchanged: update stored timezone without logging or persisting to avoid churn
96 - self.timezone = timezone
106 + if timezone == self.timezone and new_offset == self._offset_minutes:
107 + self.apply_process_timezone()
108 + return
109 +
110 + prev_tz = getattr(self, "timezone", "None")
111 + prev_off = getattr(self, "_offset_minutes", None)
112 + PrintStyle.debug(
113 + f"Changing timezone from {prev_tz} (offset {prev_off}) to {timezone} (offset {new_offset})"
114 + )
115 + self._offset_minutes = new_offset
116 + self.timezone = timezone
117 + save_dotenv_value("DEFAULT_USER_TIMEZONE", timezone)
118 + save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", str(self._offset_minutes))
119 + self.apply_process_timezone()
120 + self._last_timezone_change = datetime.now()
121 except pytz.exceptions.UnknownTimeZoneError:
98 - PrintStyle.error(f"Unknown timezone: {timezone}, defaulting to UTC")
99 - self.timezone = "UTC"
100 - self._offset_minutes = 0
101 - # save defaults to avoid future errors on startup
102 - save_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC")
103 - save_dotenv_value("DEFAULT_USER_UTC_OFFSET_MINUTES", "0")
122 + fallback_timezone = self.timezone
123 + try:
124 + pytz.timezone(fallback_timezone)
125 + except pytz.exceptions.UnknownTimeZoneError:
126 + fallback_timezone = "UTC"
127 +
128 + PrintStyle.error(f"Unknown timezone: {timezone}, keeping {fallback_timezone}")
129 + self.timezone = fallback_timezone
130 + self._offset_minutes = self._compute_offset_minutes(fallback_timezone)
131 + self.apply_process_timezone()
132
133 def localtime_str_to_utc_dt(self, localtime_str: str | None) -> datetime | None:
134 """
135 Convert a local time ISO string to a UTC datetime object.
136 Returns None if input is None or invalid.
109 - When input lacks tzinfo, assume the configured fixed UTC offset.
137 + When input lacks tzinfo, assume the configured user timezone.
138 """
139 if not localtime_str:
140 return None
141
142 try:
143 + localtime_str = localtime_str.strip().replace("Z", "+00:00")
144 # Handle both with and without timezone info
145 try:
146 # Try parsing with timezone info first
147 local_datetime_obj = datetime.fromisoformat(localtime_str)
148 if local_datetime_obj.tzinfo is None:
120 - # If no timezone info, assume fixed offset
121 - local_datetime_obj = local_datetime_obj.replace(
122 - tzinfo=dt_timezone(timedelta(minutes=self._offset_minutes))
123 - )
149 + # If no timezone info, assume the configured user timezone.
150 + local_datetime_obj = self.localize_naive_datetime(local_datetime_obj)
151 except ValueError:
125 - # If timezone parsing fails, try without timezone
126 - base = localtime_str.split('Z')[0].split('+')[0]
127 - local_datetime_obj = datetime.fromisoformat(base)
128 - local_datetime_obj = local_datetime_obj.replace(
129 - tzinfo=dt_timezone(timedelta(minutes=self._offset_minutes))
130 - )
152 + # If timezone parsing fails, try a few common local formats.
153 + cleaned = localtime_str.replace("T", " ")
154 + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
155 + try:
156 + local_datetime_obj = datetime.strptime(cleaned, fmt)
157 + local_datetime_obj = self.localize_naive_datetime(local_datetime_obj)
158 + break
159 + except ValueError:
160 + continue
161 + else:
162 + raise
163
164 # Convert to UTC
165 return local_datetime_obj.astimezone(dt_timezone.utc)
@@ -137,7 +169,7 @@ class Localization:
169
170 def utc_dt_to_localtime_str(self, utc_dt: datetime | None, sep: str = "T", timespec: str = "auto") -> str | None:
171 """
140 - Convert a UTC datetime object to a local time ISO string using the fixed UTC offset.
172 + Convert a UTC datetime object to a local time ISO string using the user's timezone.
173 Returns None if input is None.
174 """
175 if utc_dt is None:
@@ -153,9 +185,8 @@ class Localization:
185 else:
186 utc_dt = utc_dt.astimezone(dt_timezone.utc)
187
156 - # Convert to local time using fixed offset
157 - local_tz = dt_timezone(timedelta(minutes=self._offset_minutes))
158 - local_datetime_obj = utc_dt.astimezone(local_tz)
188 + # Convert to local time using the user's timezone.
189 + local_datetime_obj = utc_dt.astimezone(self.get_tzinfo())
190 return local_datetime_obj.isoformat(sep=sep, timespec=timespec)
191 except Exception as e:
192 PrintStyle.error(f"Error converting UTC datetime to localtime string: {e}")
@@ -163,8 +194,8 @@ class Localization:
194
195 def serialize_datetime(self, dt: datetime | None) -> str | None:
196 """
166 - Serialize a datetime object to ISO format string using the user's fixed UTC offset.
167 - This ensures the frontend receives dates with the correct current offset for display.
197 + Serialize a datetime object to ISO format string using the user's timezone.
198 + This ensures the frontend receives dates with the correct offset for display.
199 """
200 if dt is None:
201 return None
@@ -173,12 +204,11 @@ class Localization:
204 assert dt is not None
205
206 try:
176 - # Ensure datetime is timezone aware (if not, assume UTC)
207 + # Ensure datetime is timezone aware (if not, assume the user's timezone)
208 if dt.tzinfo is None:
178 - dt = dt.replace(tzinfo=dt_timezone.utc)
209 + dt = self.localize_naive_datetime(dt)
210
180 - local_tz = dt_timezone(timedelta(minutes=self._offset_minutes))
181 - local_dt = dt.astimezone(local_tz)
211 + local_dt = dt.astimezone(self.get_tzinfo())
212 return local_dt.isoformat()
213 except Exception as e:
214 PrintStyle.error(f"Error serializing datetime: {e}")
helpers/notification.py
+7 -5
@@ -1,9 +1,11 @@
1 from dataclasses import dataclass
2 import uuid
3 import threading
4 -from datetime import datetime, timezone, timedelta
4 +from datetime import datetime, timedelta
5 from enum import Enum
6
7 +from helpers.localization import Localization
8 +
9
10 class NotificationType(Enum):
11 INFO = "info"
@@ -53,7 +55,7 @@ class NotificationItem:
55 "title": self.title,
56 "message": self.message,
57 "detail": self.detail,
56 - "timestamp": self.timestamp.isoformat(),
58 + "timestamp": Localization.get().serialize_datetime(self.timestamp),
59 "display_time": self.display_time,
60 "read": self.read,
61 "group": self.group,
@@ -106,7 +108,7 @@ class NotificationManager:
108 existing.title = title
109 existing.message = message
110 existing.detail = detail
109 - existing.timestamp = datetime.now(timezone.utc)
111 + existing.timestamp = Localization.get().now()
112 existing.display_time = display_time
113 existing.group = group
114 existing.read = False
@@ -122,7 +124,7 @@ class NotificationManager:
124 title=title,
125 message=message,
126 detail=detail,
125 - timestamp=datetime.now(timezone.utc),
127 + timestamp=Localization.get().now(),
128 display_time=display_time,
129 id=id,
130 group=group,
@@ -149,7 +151,7 @@ class NotificationManager:
151 self.updates = [no - to_remove for no in self.updates if no >= to_remove]
152
153 def get_recent_notifications(self, seconds: int = 30) -> list[NotificationItem]:
152 - cutoff = datetime.now(timezone.utc) - timedelta(seconds=seconds)
154 + cutoff = Localization.get().now() - timedelta(seconds=seconds)
155 with self._lock:
156 return [n for n in self.notifications if n.timestamp >= cutoff]
157
helpers/persist_chat.py
+19 -11
@@ -4,6 +4,7 @@ from typing import Any
4 import uuid
5 from agent import Agent, AgentConfig, AgentContext, AgentContextType
6 from helpers import files, history
7 +from helpers.localization import Localization
8 import json
9 from initialize import initialize_agent
10
@@ -14,6 +15,18 @@ LOG_SIZE = 1000
15 CHAT_FILE_NAME = "chat.json"
16
17
18 +def _fallback_datetime_iso() -> str:
19 + return datetime.fromtimestamp(0, tz=Localization.get().get_tzinfo()).isoformat()
20 +
21 +
22 +def _parse_persisted_datetime(value: str | None) -> datetime:
23 + raw_value = value or _fallback_datetime_iso()
24 + dt = datetime.fromisoformat(raw_value)
25 + if dt.tzinfo is None:
26 + dt = Localization.get().localize_naive_datetime(dt)
27 + return dt
28 +
29 +
30 def get_chat_folder_path(ctxid: str):
31 """
32 Get the folder path for any context (chat or task).
@@ -137,15 +150,15 @@ def _serialize_context(context: AgentContext):
150 "id": context.id,
151 "name": context.name,
152 "created_at": (
140 - context.created_at.isoformat()
153 + Localization.get().serialize_datetime(context.created_at)
154 if context.created_at
142 - else datetime.fromtimestamp(0).isoformat()
155 + else _fallback_datetime_iso()
156 ),
157 "type": context.type.value,
158 "last_message": (
146 - context.last_message.isoformat()
159 + Localization.get().serialize_datetime(context.last_message)
160 if context.last_message
148 - else datetime.fromtimestamp(0).isoformat()
161 + else _fallback_datetime_iso()
162 ),
163 "agents": agents,
164 "streaming_agent": (
@@ -196,16 +209,11 @@ def _deserialize_context(data):
209 id=data.get("id", None), # get new id
210 name=data.get("name", None),
211 created_at=(
199 - datetime.fromisoformat(
200 - # older chats may not have created_at - backcompat
201 - data.get("created_at", datetime.fromtimestamp(0).isoformat())
202 - )
212 + _parse_persisted_datetime(data.get("created_at"))
213 ),
214 type=AgentContextType(data.get("type", AgentContextType.USER.value)),
215 last_message=(
206 - datetime.fromisoformat(
207 - data.get("last_message", datetime.fromtimestamp(0).isoformat())
208 - )
216 + _parse_persisted_datetime(data.get("last_message"))
217 ),
218 log=log,
219 paused=False,
helpers/self_update.py
+8 -4
@@ -5,11 +5,12 @@ import re
5 import subprocess
6 import tempfile
7 import time
8 -from datetime import UTC, datetime
8 +from datetime import datetime
9 from pathlib import Path
10 from typing import Any, Literal, TypedDict
11
12 from helpers import git, yaml
13 +from helpers.localization import Localization
14
15
16 OFFICIAL_REPO_AUTHOR = "agent0ai"
@@ -73,7 +74,7 @@ class SelectorTagOption(TypedDict):
74
75
76 def _now_iso() -> str:
76 - return datetime.now(UTC).isoformat().replace("+00:00", "Z")
77 + return Localization.get().now_iso()
78
79
80 def get_update_file_path() -> Path:
@@ -197,7 +198,10 @@ def _get_tag_release_time_in_repo(
198 timestamp = _run_git(repo_dir, "log", "-1", "--format=%ct", normalized_tag)
199 if not timestamp:
200 return ""
200 - return datetime.fromtimestamp(int(timestamp)).strftime("%Y-%m-%d %H:%M:%S")
201 + return datetime.fromtimestamp(
202 + int(timestamp),
203 + tz=Localization.get().get_tzinfo(),
204 + ).strftime("%Y-%m-%d %H:%M:%S %Z")
205 except Exception:
206 return ""
207
@@ -242,7 +246,7 @@ def build_default_backup_name(
246 current_version: str,
247 target_tag: str | None = None,
248 ) -> str:
245 - timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
249 + timestamp = Localization.get().now().strftime("%Y%m%d-%H%M%S")
250 return f"usr-{timestamp}.zip"
251
252
helpers/settings.py
+101 -3
@@ -7,6 +7,7 @@ import subprocess
7 from typing import Any, Literal, TypedDict, cast, TypeVar
8
9 import models
10 +import pytz # type: ignore
11 from helpers import runtime, defer, git, subagents
12 from . import files, dotenv
13 from helpers.print_style import PrintStyle
@@ -55,6 +56,8 @@ class Settings(TypedDict):
56
57 agent_profile: str
58 agent_knowledge_subdir: str
59 + timezone: str
60 + time_format: str
61
62 workdir_path: str
63 workdir_show: bool
@@ -143,6 +146,8 @@ class SettingsOutputAdditional(TypedDict):
146 embedding_providers: list[ModelProvider]
147 agent_subdirs: list[FieldOption]
148 knowledge_subdirs: list[FieldOption]
149 + timezones: list[FieldOption]
150 + resolved_timezone: str
151 is_dockerized: bool
152 runtime_settings: dict[str, Any]
153
@@ -154,6 +159,9 @@ class SettingsOutput(TypedDict):
159
160 PASSWORD_PLACEHOLDER = "****PSWD****"
161 API_KEY_PLACEHOLDER = "************"
162 +TIMEZONE_AUTO = "auto"
163 +TIME_FORMAT_12H = "12h"
164 +TIME_FORMAT_24H = "24h"
165
166 SETTINGS_FILE = files.get_abs_path("usr/settings.json")
167 _settings: Settings | None = None
@@ -175,6 +183,49 @@ def _ensure_option_present(options: list[OptionT] | None, current_value: str | N
183 opts.insert(0, cast(OptionT, {"value": current_value, "label": current_value}))
184 return opts
185
186 +
187 +def _is_valid_timezone(value: str) -> bool:
188 + try:
189 + pytz.timezone(value)
190 + return True
191 + except pytz.exceptions.UnknownTimeZoneError:
192 + return False
193 +
194 +
195 +def _normalize_timezone_setting(value: Any, default: str = TIMEZONE_AUTO) -> str:
196 + timezone = str(value or "").strip()
197 + if timezone.lower() == TIMEZONE_AUTO:
198 + return TIMEZONE_AUTO
199 + if _is_valid_timezone(timezone):
200 + return timezone
201 + return default if default == TIMEZONE_AUTO or _is_valid_timezone(default) else TIMEZONE_AUTO
202 +
203 +
204 +def _normalize_time_format(value: Any, default: str = TIME_FORMAT_12H) -> str:
205 + time_format = str(value or "").strip().lower()
206 + if time_format in {TIME_FORMAT_12H, TIME_FORMAT_24H}:
207 + return time_format
208 + return default if default in {TIME_FORMAT_12H, TIME_FORMAT_24H} else TIME_FORMAT_12H
209 +
210 +
211 +def _resolve_runtime_timezone(setting_value: str, browser_timezone: str | None = None) -> str:
212 + if setting_value == TIMEZONE_AUTO:
213 + candidate = str(browser_timezone or "").strip()
214 + if _is_valid_timezone(candidate):
215 + return candidate
216 + try:
217 + from helpers.localization import Localization
218 +
219 + return Localization.get().get_timezone()
220 + except Exception:
221 + return "UTC"
222 + return _normalize_timezone_setting(setting_value, default="UTC")
223 +
224 +
225 +def _timezone_options() -> list[FieldOption]:
226 + return [{"value": timezone, "label": timezone} for timezone in pytz.common_timezones]
227 +
228 +
229 def convert_out(settings: Settings) -> SettingsOutput:
230 out = SettingsOutput(
231 settings = settings.copy(),
@@ -187,6 +238,8 @@ def convert_out(settings: Settings) -> SettingsOutput:
238 if item["key"] != "_example"],
239 knowledge_subdirs=[{"value": subdir, "label": subdir}
240 for subdir in files.get_subdirectories("knowledge", exclude="default")],
241 + timezones=_timezone_options(),
242 + resolved_timezone="UTC",
243 runtime_settings={},
244 ),
245 )
@@ -208,6 +261,9 @@ def convert_out(settings: Settings) -> SettingsOutput:
261
262 additional["agent_subdirs"] = _ensure_option_present(additional.get("agent_subdirs"), current.get("agent_profile"))
263 additional["knowledge_subdirs"] = _ensure_option_present(additional.get("knowledge_subdirs"), current.get("agent_knowledge_subdir"))
264 + if current.get("timezone") != TIMEZONE_AUTO:
265 + additional["timezones"] = _ensure_option_present(additional.get("timezones"), current.get("timezone"))
266 + additional["resolved_timezone"] = _resolve_runtime_timezone(current.get("timezone", TIMEZONE_AUTO))
267
268 # masked api keys
269 providers = get_providers("chat") + get_providers("embedding")
@@ -294,13 +350,13 @@ def set_runtime_settings_snapshot(settings: Settings) -> None:
350 _runtime_settings_snapshot = settings.copy()
351
352
297 -def set_settings(settings: Settings, apply: bool = True):
353 +def set_settings(settings: Settings, apply: bool = True, browser_timezone: str | None = None):
354 global _settings
355 previous = _settings
356 _settings = normalize_settings(settings)
357 _write_settings_file(_settings)
358 if apply:
303 - _apply_settings(previous)
359 + _apply_settings(previous, browser_timezone)
360 return reload_settings()
361
362
@@ -344,6 +400,8 @@ def normalize_settings(settings: Settings) -> Settings:
400
401 # mcp server token is set automatically
402 copy["mcp_server_token"] = create_auth_token()
403 + copy["timezone"] = _normalize_timezone_setting(copy.get("timezone"), default["timezone"])
404 + copy["time_format"] = _normalize_time_format(copy.get("time_format"), default["time_format"])
405
406 return copy
407
@@ -439,6 +497,8 @@ def get_default_settings() -> Settings:
497 root_password="",
498 agent_profile=get_default_value("agent_profile", "agent0"),
499 agent_knowledge_subdir=get_default_value("agent_knowledge_subdir", "custom"),
500 + timezone=_normalize_timezone_setting(get_default_value("timezone", TIMEZONE_AUTO)),
501 + time_format=_normalize_time_format(get_default_value("time_format", TIME_FORMAT_12H)),
502 workdir_path=get_default_value("workdir_path", files.get_abs_path_dockerized("usr/workdir")),
503 workdir_show=get_default_value("workdir_show", True),
504 workdir_max_depth=get_default_value("workdir_max_depth", 5),
@@ -466,9 +526,47 @@ def get_default_settings() -> Settings:
526 )
527
528
469 -def _apply_settings(previous: Settings | None):
529 +def _apply_timezone_setting(previous: Settings | None, browser_timezone: str | None = None) -> None:
530 + if not _settings:
531 + return
532 +
533 + from helpers.localization import Localization
534 +
535 + localization = Localization.get()
536 + previous_timezone = localization.get_timezone()
537 + target_timezone = _resolve_runtime_timezone(_settings["timezone"], browser_timezone)
538 + if (
539 + previous
540 + and _settings["timezone"] == previous.get("timezone")
541 + and _settings["timezone"] != TIMEZONE_AUTO
542 + and previous_timezone == target_timezone
543 + ):
544 + return
545 +
546 + localization.set_timezone(target_timezone)
547 + current_timezone = localization.get_timezone()
548 + if current_timezone == previous_timezone:
549 + return
550 +
551 + try:
552 + from helpers import plugins
553 +
554 + plugins.call_plugin_hook(
555 + "_office",
556 + "timezone_changed",
557 + None,
558 + previous_timezone=previous_timezone,
559 + timezone=current_timezone,
560 + )
561 + except Exception:
562 + return
563 +
564 +
565 +def _apply_settings(previous: Settings | None, browser_timezone: str | None = None):
566 global _settings
567 if _settings:
568 + _apply_timezone_setting(previous, browser_timezone)
569 +
570 from agent import AgentContext
571 from initialize import initialize_agent
572
helpers/state_snapshot.py
+22 -2
@@ -200,7 +200,7 @@ def _coerce_state_request_inputs(
200 timezone: Any,
201 ) -> StateRequestV1:
202 tz = timezone if isinstance(timezone, str) and timezone else None
203 - tz = tz or get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC")
203 + tz = tz or get_dotenv_value("DEFAULT_USER_TIMEZONE", Localization.get().get_timezone())
204
205 ctxid: str | None = context.strip() if isinstance(context, str) else None
206 if ctxid == "":
@@ -242,7 +242,12 @@ def advance_state_request_after_snapshot(
242 async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1:
243 """Build a poll-shaped snapshot for both /poll and state_push."""
244
245 - Localization.get().set_timezone(request.timezone)
245 + localization = Localization.get()
246 + previous_timezone = localization.get_timezone()
247 + localization.set_timezone(request.timezone)
248 + current_timezone = localization.get_timezone()
249 + if current_timezone != previous_timezone:
250 + _notify_timezone_changed(previous_timezone, current_timezone)
251
252 ctxid = request.context if isinstance(request.context, str) else ""
253 ctxid = ctxid.strip()
@@ -339,6 +344,21 @@ async def build_snapshot_from_request(*, request: StateRequestV1) -> SnapshotV1:
344 return snapshot
345
346
347 +def _notify_timezone_changed(previous_timezone: str, current_timezone: str) -> None:
348 + try:
349 + from helpers import plugins
350 +
351 + plugins.call_plugin_hook(
352 + "_office",
353 + "timezone_changed",
354 + None,
355 + previous_timezone=previous_timezone,
356 + timezone=current_timezone,
357 + )
358 + except Exception:
359 + return
360 +
361 +
362 async def build_snapshot(
363 *,
364 context: str | None,
helpers/task_scheduler.py
+70 -42
@@ -41,6 +41,35 @@ def normalize_schedule_timezone(timezone_name: str | None) -> str:
41 return Localization.get().get_timezone()
42 return name
43
44 +
45 +def _now() -> datetime:
46 + localization = Localization.get()
47 + now = getattr(localization, "now", None)
48 + if callable(now):
49 + return now()
50 +
51 + try:
52 + tzinfo = pytz.timezone(localization.get_timezone())
53 + except Exception:
54 + tzinfo = pytz.timezone("UTC")
55 + return datetime.now(tzinfo)
56 +
57 +
58 +def _localize_task_datetime(dt: datetime) -> datetime:
59 + if dt.tzinfo is not None:
60 + return dt
61 +
62 + localization = Localization.get()
63 + localize = getattr(localization, "localize_naive_datetime", None)
64 + if callable(localize):
65 + return localize(dt)
66 +
67 + try:
68 + tzinfo = pytz.timezone(localization.get_timezone())
69 + except Exception:
70 + tzinfo = pytz.timezone("UTC")
71 + return tzinfo.localize(dt)
72 +
73 # ----------------------
74 # Task Models
75 # ----------------------
@@ -77,29 +106,31 @@ class TaskPlan(BaseModel):
106 done: list[datetime] = Field(default_factory=list)
107
108 @classmethod
80 - def create(cls, todo: list[datetime] = list(), in_progress: datetime | None = None, done: list[datetime] = list()):
109 + def create(
110 + cls,
111 + todo: list[datetime] | None = None,
112 + in_progress: datetime | None = None,
113 + done: list[datetime] | None = None,
114 + ):
115 + todo = list(todo or [])
116 + done = list(done or [])
117 if todo:
118 for idx, dt in enumerate(todo):
83 - if dt.tzinfo is None:
84 - todo[idx] = pytz.timezone("UTC").localize(dt)
119 + todo[idx] = _localize_task_datetime(dt)
120 if in_progress:
86 - if in_progress.tzinfo is None:
87 - in_progress = pytz.timezone("UTC").localize(in_progress)
121 + in_progress = _localize_task_datetime(in_progress)
122 if done:
123 for idx, dt in enumerate(done):
90 - if dt.tzinfo is None:
91 - done[idx] = pytz.timezone("UTC").localize(dt)
124 + done[idx] = _localize_task_datetime(dt)
125 return cls(todo=todo, in_progress=in_progress, done=done)
126
127 def add_todo(self, launch_time: datetime):
95 - if launch_time.tzinfo is None:
96 - launch_time = pytz.timezone("UTC").localize(launch_time)
128 + launch_time = _localize_task_datetime(launch_time)
129 self.todo.append(launch_time)
130 self.todo = sorted(self.todo)
131
132 def set_in_progress(self, launch_time: datetime):
101 - if launch_time.tzinfo is None:
102 - launch_time = pytz.timezone("UTC").localize(launch_time)
133 + launch_time = _localize_task_datetime(launch_time)
134 if launch_time not in self.todo:
135 raise ValueError(f"Launch time {launch_time} not in todo list")
136 self.todo.remove(launch_time)
@@ -107,8 +138,7 @@ class TaskPlan(BaseModel):
138 self.in_progress = launch_time
139
140 def set_done(self, launch_time: datetime):
110 - if launch_time.tzinfo is None:
111 - launch_time = pytz.timezone("UTC").localize(launch_time)
141 + launch_time = _localize_task_datetime(launch_time)
142 if launch_time != self.in_progress:
143 raise ValueError(f"Launch time {launch_time} is not the same as in progress time {self.in_progress}")
144 if launch_time in self.done:
@@ -124,8 +154,7 @@ class TaskPlan(BaseModel):
154 next_launch_time = self.get_next_launch_time()
155 if next_launch_time is None:
156 return None
127 - # return next launch time if current datetime utc is later than next launch time
128 - if datetime.now(timezone.utc) > next_launch_time:
157 + if _now() > next_launch_time:
158 return next_launch_time
159 return None
160
@@ -140,8 +169,8 @@ class BaseTask(BaseModel):
169 attachments: list[str] = Field(default_factory=list)
170 project_name: str | None = Field(default=None)
171 project_color: str | None = Field(default=None)
143 - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
144 - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
172 + created_at: datetime = Field(default_factory=_now)
173 + updated_at: datetime = Field(default_factory=_now)
174 last_run: datetime | None = None
175 last_result: str | None = None
176
@@ -164,32 +193,32 @@ class BaseTask(BaseModel):
193 with self._lock:
194 if name is not None:
195 self.name = name
167 - self.updated_at = datetime.now(timezone.utc)
196 + self.updated_at = _now()
197 if state is not None:
198 self.state = state
170 - self.updated_at = datetime.now(timezone.utc)
199 + self.updated_at = _now()
200 if system_prompt is not None:
201 self.system_prompt = system_prompt
173 - self.updated_at = datetime.now(timezone.utc)
202 + self.updated_at = _now()
203 if prompt is not None:
204 self.prompt = prompt
176 - self.updated_at = datetime.now(timezone.utc)
205 + self.updated_at = _now()
206 if attachments is not None:
207 self.attachments = attachments
179 - self.updated_at = datetime.now(timezone.utc)
208 + self.updated_at = _now()
209 if last_run is not None:
210 self.last_run = last_run
182 - self.updated_at = datetime.now(timezone.utc)
211 + self.updated_at = _now()
212 if last_result is not None:
213 self.last_result = last_result
185 - self.updated_at = datetime.now(timezone.utc)
214 + self.updated_at = _now()
215 if context_id is not None:
216 self.context_id = context_id
188 - self.updated_at = datetime.now(timezone.utc)
217 + self.updated_at = _now()
218 for key, value in kwargs.items():
219 if value is not None:
220 setattr(self, key, value)
192 - self.updated_at = datetime.now(timezone.utc)
221 + self.updated_at = _now()
222
223 def check_schedule(self, frequency_seconds: float = 60.0) -> bool:
224 return False
@@ -204,7 +233,7 @@ class BaseTask(BaseModel):
233 next_run = self.get_next_run()
234 if next_run is None:
235 return None
207 - return int((next_run - datetime.now(timezone.utc)).total_seconds() / 60)
236 + return int((next_run - _now()).total_seconds() / 60)
237
238 async def on_run(self):
239 pass
@@ -214,7 +243,7 @@ class BaseTask(BaseModel):
243 # This helps track when the task actually finished, regardless of success/error
244 await TaskScheduler.get().update_task(
245 self.uuid,
217 - updated_at=datetime.now(timezone.utc)
246 + updated_at=_now()
247 )
248
249 async def on_error(self, error: str):
@@ -224,7 +253,7 @@ class BaseTask(BaseModel):
253 updated_task = await scheduler.update_task(
254 self.uuid,
255 state=TaskState.ERROR,
227 - last_run=datetime.now(timezone.utc),
256 + last_run=_now(),
257 last_result=f"ERROR: {error}"
258 )
259 if not updated_task:
@@ -240,7 +269,7 @@ class BaseTask(BaseModel):
269 updated_task = await scheduler.update_task(
270 self.uuid,
271 state=TaskState.IDLE,
243 - last_run=datetime.now(timezone.utc),
272 + last_run=_now(),
273 last_result=result
274 )
275 if not updated_task:
@@ -261,7 +290,7 @@ class AdHocTask(BaseTask):
290 system_prompt: str,
291 prompt: str,
292 token: str,
264 - attachments: list[str] = list(),
293 + attachments: list[str] | None = None,
294 context_id: str | None = None,
295 project_name: str | None = None,
296 project_color: str | None = None
@@ -269,7 +298,7 @@ class AdHocTask(BaseTask):
298 return cls(name=name,
299 system_prompt=system_prompt,
300 prompt=prompt,
272 - attachments=attachments,
301 + attachments=list(attachments or []),
302 token=token,
303 context_id=context_id,
304 project_name=project_name,
@@ -309,7 +338,7 @@ class ScheduledTask(BaseTask):
338 system_prompt: str,
339 prompt: str,
340 schedule: TaskSchedule,
312 - attachments: list[str] = list(),
341 + attachments: list[str] | None = None,
342 context_id: str | None = None,
343 timezone: str | None = None,
344 project_name: str | None = None,
@@ -324,7 +353,7 @@ class ScheduledTask(BaseTask):
353 return cls(name=name,
354 system_prompt=system_prompt,
355 prompt=prompt,
327 - attachments=attachments,
356 + attachments=list(attachments or []),
357 schedule=schedule,
358 context_id=context_id,
359 project_name=project_name,
@@ -361,8 +390,7 @@ class ScheduledTask(BaseTask):
390 task_timezone = pytz.timezone(self.schedule.timezone)
391
392 # Get reference time in task's timezone (by default now - frequency_seconds)
364 - reference_time = datetime.now(timezone.utc) - timedelta(seconds=frequency_seconds)
365 - reference_time = reference_time.astimezone(task_timezone)
393 + reference_time = (_now() - timedelta(seconds=frequency_seconds)).astimezone(task_timezone)
394
395 # Get next run time as seconds until next execution
396 next_run_seconds: Optional[float] = crontab.next( # type: ignore
@@ -400,7 +428,7 @@ class PlannedTask(BaseTask):
428 system_prompt: str,
429 prompt: str,
430 plan: TaskPlan,
403 - attachments: list[str] = list(),
431 + attachments: list[str] | None = None,
432 context_id: str | None = None,
433 project_name: str | None = None,
434 project_color: str | None = None
@@ -409,7 +437,7 @@ class PlannedTask(BaseTask):
437 system_prompt=system_prompt,
438 prompt=prompt,
439 plan=plan,
412 - attachments=attachments,
440 + attachments=list(attachments or []),
441 context_id=context_id,
442 project_name=project_name,
443 project_color=project_color)
@@ -1095,9 +1123,9 @@ def parse_task_plan(plan_data: Dict[str, Any]) -> TaskPlan:
1123 if dt_str:
1124 parsed_dt = parse_datetime(dt_str)
1125 if parsed_dt:
1098 - # Ensure datetime is timezone-aware (use UTC if not specified)
1126 + # Ensure datetime is timezone-aware (use the user's timezone if not specified)
1127 if parsed_dt.tzinfo is None:
1100 - parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
1128 + parsed_dt = _localize_task_datetime(parsed_dt)
1129 todo_dates.append(parsed_dt)
1130
1131 # Parse in_progress with validation
@@ -1106,7 +1134,7 @@ def parse_task_plan(plan_data: Dict[str, Any]) -> TaskPlan:
1134 in_progress = parse_datetime(plan_data.get('in_progress'))
1135 # Ensure datetime is timezone-aware
1136 if in_progress and in_progress.tzinfo is None:
1109 - in_progress = in_progress.replace(tzinfo=timezone.utc)
1137 + in_progress = _localize_task_datetime(in_progress)
1138
1139 # Parse done items with validation
1140 done_dates = []
@@ -1116,7 +1144,7 @@ def parse_task_plan(plan_data: Dict[str, Any]) -> TaskPlan:
1144 if parsed_dt:
1145 # Ensure datetime is timezone-aware
1146 if parsed_dt.tzinfo is None:
1119 - parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
1147 + parsed_dt = _localize_task_datetime(parsed_dt)
1148 done_dates.append(parsed_dt)
1149
1150 # Sort dates for better usability
helpers/ui_server.py
+13 -3
@@ -41,10 +41,10 @@ UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 * 1024
41
42 def configure_process_environment() -> None:
43 logging.getLogger().setLevel(logging.WARNING)
44 - os.environ["TZ"] = "UTC"
44 os.environ["TOKENIZERS_PARALLELISM"] = "false"
46 - if hasattr(time, "tzset"):
47 - time.tzset()
45 + from helpers.localization import Localization
46 +
47 + Localization.get().apply_process_timezone()
48
49
50 @dataclass
@@ -229,6 +229,14 @@ class UiRouteHandlers:
229 "version": "unknown",
230 "commit_time": "unknown",
231 }
232 + try:
233 + user_timezone_setting = str(settings_helper.get_settings().get("timezone", "auto"))
234 + except Exception:
235 + user_timezone_setting = "auto"
236 + try:
237 + user_time_format_setting = str(settings_helper.get_settings().get("time_format", "12h"))
238 + except Exception:
239 + user_time_format_setting = "12h"
240
241 index = files.read_file("webui/index.html")
242 return files.replace_placeholders_text(
@@ -238,6 +246,8 @@ class UiRouteHandlers:
246 runtime_id=runtime.get_runtime_id(),
247 runtime_is_development=("true" if runtime.is_development() else "false"),
248 logged_in=("true" if login.get_credentials_hash() else "false"),
249 + user_timezone_setting=user_timezone_setting,
250 + user_time_format_setting=user_time_format_setting,
251 )
252
253 @requires_auth
helpers/virtual_desktop.py
+2
@@ -13,6 +13,7 @@ from typing import Any, Callable
13 from urllib.parse import quote, urlencode
14
15 from helpers import files
16 +from helpers.localization import Localization
17
18
19 STATE_DIR = Path(files.get_abs_path("usr", "plugins", "_desktop", "virtual_desktop"))
@@ -563,6 +564,7 @@ def _display_env(display: int, *, xauthority: str = "", home: str = "") -> dict[
564 **os.environ,
565 "DISPLAY": f":{display}",
566 "XDG_RUNTIME_DIR": str(runtime_dir),
567 + "TZ": Localization.get().get_timezone(),
568 }
569 if home:
570 env["HOME"] = home
helpers/wait.py
+6 -6
@@ -1,6 +1,6 @@
1 import asyncio
2 -from datetime import datetime, timezone
2
3 +from helpers.localization import Localization
4 from helpers.print_style import PrintStyle
5
6
@@ -41,20 +41,20 @@ def format_remaining_time(total_seconds: float) -> str:
41
42 async def managed_wait(agent, target_time, is_duration_wait, log, get_heading_callback):
43
44 - while datetime.now(timezone.utc) < target_time:
45 - before_intervention = datetime.now(timezone.utc)
44 + while Localization.get().now() < target_time:
45 + before_intervention = Localization.get().now()
46 await agent.handle_intervention()
47 - after_intervention = datetime.now(timezone.utc)
47 + after_intervention = Localization.get().now()
48
49 if is_duration_wait:
50 pause_duration = after_intervention - before_intervention
51 if pause_duration.total_seconds() > 1.5: # Adjust for pauses longer than the sleep cycle
52 target_time += pause_duration
53 PrintStyle.info(
54 - f"Wait extended by {pause_duration.total_seconds():.1f}s to {target_time.isoformat()}...",
54 + f"Wait extended by {pause_duration.total_seconds():.1f}s to {Localization.get().serialize_datetime(target_time)}...",
55 )
56
57 - current_time = datetime.now(timezone.utc)
57 + current_time = Localization.get().now()
58 if current_time >= target_time:
59 break
60
lib/browser/extract_dom.js
+7 -1
@@ -4,7 +4,13 @@ function extractDOM([
4 guidName = "data-a0gu1d",
5 ]) {
6 let elementCounter = 0;
7 - const time = new Date().toISOString().slice(11, -1).replace(/[:.]/g, "");
7 + const now = new Date();
8 + const time = [
9 + String(now.getHours()).padStart(2, "0"),
10 + String(now.getMinutes()).padStart(2, "0"),
11 + String(now.getSeconds()).padStart(2, "0"),
12 + String(now.getMilliseconds()).padStart(3, "0"),
13 + ].join("");
14 const ignoredTags = [
15 "style",
16 "script",
plugins/_chat_branching/api/branch_chat.py
+3 -3
@@ -1,7 +1,7 @@
1 import json
2 -from datetime import datetime
2
3 from helpers.api import ApiHandler, Input, Output, Request, Response
4 +from helpers.localization import Localization
5 from helpers.persist_chat import (
6 _serialize_context,
7 _deserialize_context,
@@ -128,7 +128,7 @@ class BranchChat(ApiHandler):
128 # Give the branch a distinguishable name
129 src_name = data.get("name") or "Chat"
130 data["name"] = f"{src_name} (branch)"
131 - data["created_at"] = datetime.now().isoformat()
131 + data["created_at"] = Localization.get().now_iso()
132
133 # Deserialize into a brand-new context (new id, fresh agent config)
134 new_context = _deserialize_context(data)
@@ -144,4 +144,4 @@ class BranchChat(ApiHandler):
144 "ok": True,
145 "ctxid": new_context.id,
146 "message": "Chat branched successfully.",
147 - }
\ No newline at end of file
147 + }
plugins/_chat_compaction/helpers/compactor.py
+2 -2
@@ -1,7 +1,6 @@
1 """Core compaction logic for the compaction plugin."""
2 import os
3 from collections import deque
4 -from datetime import datetime
4
5 import models as models_module
6 from agent import Agent
@@ -14,6 +13,7 @@ from helpers.persist_chat import (
13 remove_msg_files,
14 )
15 from helpers.state_monitor_integration import mark_dirty_all
16 +from helpers.localization import Localization
17
18 MIN_COMPACTION_TOKENS = 1000
19 COMPACTION_CHUNK_TARGET_RATIO = 0.9
@@ -34,7 +34,7 @@ def _save_pre_compaction_backup(context, full_text: str) -> dict[str, str]:
34
35 Returns dict with 'json' and 'txt' absolute file paths.
36 """
37 - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
37 + timestamp = Localization.get().now().strftime("%Y%m%d-%H%M%S")
38 backup_dir = os.path.join(get_chat_folder_path(context.id), "backups")
39 os.makedirs(backup_dir, exist_ok=True)
40
plugins/_desktop/helpers/desktop_session.py
+58
@@ -18,6 +18,7 @@ from pathlib import Path
18 from typing import Any
19
20 from helpers import files, virtual_desktop
21 +from helpers.localization import Localization
22 from plugins._desktop.helpers import desktop_state
23 from plugins._office.helpers import document_store, libreoffice
24
@@ -103,6 +104,7 @@ class DesktopSession:
104 process_ids: dict[str, int] = field(default_factory=dict)
105 owns_processes: bool = True
106 started_at: float = field(default_factory=time.time)
107 + timezone: str = field(default_factory=lambda: Localization.get().get_timezone())
108
109 def alive(self) -> bool:
110 return _running(self.processes.get("xpra")) or _pid_is_running(self.process_ids.get("xpra", 0))
@@ -456,6 +458,27 @@ class DesktopSessionManager:
458 self._terminate_session(session)
459 self._remove_manifest(session.session_id)
460
461 + def sync_timezone(self, timezone: str | None = None) -> dict[str, Any]:
462 + target_timezone = str(timezone or Localization.get().get_timezone()).strip()
463 + if not target_timezone:
464 + target_timezone = Localization.get().get_timezone()
465 +
466 + with self._lock:
467 + self._reap_dead_locked()
468 + session = self._sessions.get(SYSTEM_SESSION_ID)
469 + if not session or not session.alive():
470 + return {"ok": True, "restarted": False, "reason": "no_active_desktop"}
471 + if session.timezone == target_timezone:
472 + return {"ok": True, "restarted": False, "timezone": target_timezone}
473 +
474 + replacement = self._restart_system_desktop_for_timezone_locked(session)
475 + return {
476 + "ok": True,
477 + "restarted": True,
478 + "session_id": replacement.session_id,
479 + "timezone": replacement.timezone,
480 + }
481 +
482 def _document_for_save(self, session: DesktopSession, file_id: str = "") -> dict[str, Any] | None:
483 normalized = str(file_id or "").strip()
484 if normalized == SYSTEM_FILE_ID:
@@ -471,6 +494,32 @@ class DesktopSessionManager:
494 return document_store.register_document(path)
495 return None
496
497 + def _restart_system_desktop_for_timezone_locked(self, session: DesktopSession) -> DesktopSession:
498 + try:
499 + doc = self._document_for_save(session, session.file_id)
500 + except Exception:
501 + doc = None
502 + if doc:
503 + try:
504 + self.save(session.session_id, str(doc.get("file_id") or ""))
505 + except Exception:
506 + pass
507 +
508 + virtual_desktop.unregister_session(session.token)
509 + self._sessions.pop(session.session_id, None)
510 + self._terminate_session(session, include_rehydrated=True)
511 + self._remove_manifest(session.session_id)
512 +
513 + replacement = self._ensure_system_desktop_locked()
514 + if doc:
515 + self._open_document_locked(replacement, doc)
516 + replacement.file_id = str(doc["file_id"])
517 + replacement.extension = str(doc["extension"])
518 + replacement.path = str(doc["path"])
519 + replacement.title = str(doc["basename"])
520 + self._write_manifest(replacement)
521 + return replacement
522 +
523 def _register_virtual_desktop(self, session: DesktopSession) -> None:
524 virtual_desktop.register_session(
525 token=session.token,
@@ -482,8 +531,11 @@ class DesktopSessionManager:
531 )
532
533 def _ensure_system_desktop_locked(self) -> DesktopSession:
534 + target_timezone = Localization.get().get_timezone()
535 existing = self._sessions.get(SYSTEM_SESSION_ID)
536 if existing and existing.alive():
537 + if existing.timezone != target_timezone:
538 + return self._restart_system_desktop_for_timezone_locked(existing)
539 self._prepare_desktop_url_bridge(existing)
540 self._refresh_xfce_desktop(existing)
541 return existing
@@ -492,6 +544,8 @@ class DesktopSessionManager:
544 if existing:
545 self._sessions[existing.session_id] = existing
546 self._register_virtual_desktop(existing)
547 + if existing.timezone != target_timezone:
548 + return self._restart_system_desktop_for_timezone_locked(existing)
549 self._prepare_desktop_url_bridge(existing)
550 self._refresh_xfce_desktop(existing)
551 return existing
@@ -513,6 +567,7 @@ class DesktopSessionManager:
567 token=SYSTEM_SESSION_ID,
568 url=_xpra_url(SYSTEM_SESSION_ID),
569 profile_dir=profile_dir,
570 + timezone=target_timezone,
571 )
572 try:
573 self._prepare_profile(session)
@@ -572,6 +627,7 @@ class DesktopSessionManager:
627 process_ids=process_ids,
628 owns_processes=False,
629 started_at=float(payload.get("started_at") or time.time()),
630 + timezone=str(payload.get("timezone") or Localization.get().get_timezone()),
631 )
632 except Exception:
633 return None
@@ -1431,6 +1487,7 @@ fi
1487 **os.environ,
1488 "HOME": str(session.profile_dir),
1489 "LANG": os.environ.get("LANG") or "C.UTF-8",
1490 + "TZ": session.timezone or Localization.get().get_timezone(),
1491 }
1492 browser_bridge = _url_bridge_script_path(session)
1493 if browser_bridge.exists():
@@ -1532,6 +1589,7 @@ fi
1589 "width": session.width,
1590 "height": session.height,
1591 "started_at": session.started_at,
1592 + "timezone": session.timezone,
1593 "owner_pid": os.getpid(),
1594 "pids": pids,
1595 }
plugins/_email_integration/extensions/python/job_loop/_10_email_poll.py
+2 -2
@@ -1,12 +1,12 @@
1 """Per-handler email poll loop with configurable seconds/cron intervals."""
2
3 import asyncio
4 -from datetime import datetime, timezone
4 from typing import Any
5
6 from crontab import CronTab
7
8 from helpers.extension import Extension
9 +from helpers.localization import Localization
10 from helpers.errors import format_error
11 from helpers.print_style import PrintStyle
12 from helpers import plugins
@@ -96,7 +96,7 @@ def _get_sleep_seconds(handler_cfg: dict) -> float:
96 expr = handler_cfg.get("poll_interval_cron", "*/2 * * * *")
97 try:
98 cron = CronTab(expr)
99 - next_sec = cron.next(now=datetime.now(timezone.utc)) # type: ignore[union-attr]
99 + next_sec = cron.next(now=Localization.get().now()) # type: ignore[union-attr]
100 return max(next_sec, MIN_INTERVAL)
101 except Exception:
102 return DEFAULT_INTERVAL
plugins/_memory/api/memory_dashboard.py
+25 -3
@@ -1,5 +1,6 @@
1 from helpers.api import ApiHandler, Request, Response
2 from helpers import files
3 +from helpers.localization import Localization
4 from models import ModelConfig, ModelType
5 from langchain_core.documents import Document
6 from agent import AgentContext
@@ -174,8 +175,10 @@ class MemoryDashboard(ApiHandler):
175
176 # sort by timestamp
177 def get_sort_key(m):
177 - timestamp = m.metadata.get("timestamp", "0000-00-00 00:00:00")
178 - return timestamp
178 + timestamp = self._serialize_memory_timestamp(
179 + m.metadata.get("timestamp", "0000-00-00 00:00:00")
180 + )
181 + return timestamp or "0000-00-00T00:00:00"
182
183 memories.sort(key=get_sort_key, reverse=True)
184
@@ -214,10 +217,11 @@ class MemoryDashboard(ApiHandler):
217 def _format_memory_for_dashboard(self, m: Document) -> dict:
218 """Format a memory document for the dashboard."""
219 metadata = m.metadata
220 + timestamp = self._serialize_memory_timestamp(metadata.get("timestamp", "unknown"))
221 return {
222 "id": metadata.get("id", "unknown"),
223 "area": metadata.get("area", "unknown"),
220 - "timestamp": metadata.get("timestamp", "unknown"),
224 + "timestamp": timestamp,
225 # "content_preview": m.page_content[:200]
226 # + ("..." if len(m.page_content) > 200 else ""),
227 "content_full": m.page_content,
@@ -229,6 +233,24 @@ class MemoryDashboard(ApiHandler):
233 "metadata": metadata, # Include full metadata for advanced users
234 }
235
236 + def _serialize_memory_timestamp(self, value) -> str:
237 + if not value or value == "unknown":
238 + return "unknown"
239 +
240 + if isinstance(value, str):
241 + value = value.strip()
242 + if not value or value == "unknown":
243 + return "unknown"
244 +
245 + localization = Localization.get()
246 + if isinstance(value, str):
247 + parsed = localization.localtime_str_to_utc_dt(value)
248 + if parsed is None:
249 + return value
250 + return localization.utc_dt_to_localtime_str(parsed, timespec="seconds") or value
251 +
252 + return localization.serialize_datetime(value) or str(value)
253 +
254 async def _update_memory(self, input: dict) -> dict:
255 try:
256 memory_subdir = input.get("memory_subdir")
plugins/_memory/helpers/memory.py
+2 -2
@@ -1,4 +1,3 @@
1 -from datetime import datetime
1 from typing import Any, List, Sequence
2 from langchain.storage import InMemoryByteStore, LocalFileStore
3 from langchain.embeddings import CacheBackedEmbeddings
@@ -24,6 +23,7 @@ import numpy as np
23
24 from helpers.print_style import PrintStyle
25 from helpers import files, plugins, projects
26 +from helpers.localization import Localization
27 from langchain_core.documents import Document
28 from . import knowledge_import
29 from helpers.log import Log, LogItem
@@ -609,7 +609,7 @@ class Memory:
609
610 @staticmethod
611 def get_timestamp():
612 - return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
612 + return Localization.get().now_iso(timespec="seconds")
613
614
615 def get_custom_knowledge_subdir_abs(agent: Agent) -> str:
plugins/_memory/helpers/memory_consolidation.py
+2 -2
@@ -1,7 +1,6 @@
1 import asyncio
2 import json
3 from dataclasses import dataclass, field
4 -from datetime import datetime, timezone
4 from typing import Any, Dict, List, Optional
5 from enum import Enum
6
@@ -9,6 +8,7 @@ from langchain_core.documents import Document
8
9 from plugins._memory.helpers.memory import Memory
10 from helpers.dirty_json import DirtyJson
11 +from helpers.localization import Localization
12 from helpers.log import LogItem
13 from helpers.print_style import PrintStyle
14 from agent import Agent
@@ -753,7 +753,7 @@ class MemoryConsolidator:
753
754 def _get_timestamp(self) -> str:
755 """Get current timestamp in standard format."""
756 - return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
756 + return Localization.get().now_iso(timespec="seconds")
757
758
759 # Factory function for easy instantiation
plugins/_memory/webui/memory-dashboard-store.js
+29 -29
@@ -3,6 +3,12 @@ import { getContext } from "/index.js";
3 import * as API from "/js/api.js";
4 import { openModal, closeModal } from "/js/modals.js";
5 import { store as notificationStore } from "/components/notifications/notification-store.js";
6 +import {
7 + getCurrentUserDateString,
8 + getCurrentUserISOString,
9 + getUserHour12,
10 + getUserTimezone,
11 +} from "/js/time-utils.js";
12 const MEMORY_DASHBOARD_API = "/plugins/_memory/memory_dashboard";
13
14 // Helper function for toasts
@@ -407,7 +413,7 @@ ${memory.content_full}
413 if (selectedMemories.length === 0) return;
414
415 const exportData = {
410 - export_timestamp: new Date().toISOString(),
416 + export_timestamp: getCurrentUserISOString(),
417 memory_subdir: this.selectedMemorySubdir,
418 total_memories: selectedMemories.length,
419 memories: selectedMemories.map((memory) => ({
@@ -426,7 +432,7 @@ ${memory.content_full}
432 const blob = new Blob([jsonString], { type: "application/json" });
433 const url = URL.createObjectURL(blob);
434
429 - const timestamp = new Date().toISOString().split("T")[0];
435 + const timestamp = getCurrentUserDateString();
436 const filename = `memories_${this.selectedMemorySubdir}_selected_${selectedMemories.length}_${timestamp}.json`;
437
438 const a = document.createElement("a");
@@ -468,34 +474,28 @@ ${memory.content_full}
474 }
475
476 if (compact) {
477 + const hour12 = getUserHour12();
478 // For table display: MM/DD HH:mm
472 - return (
473 - date.toLocaleDateString("en-US", {
474 - month: "2-digit",
475 - day: "2-digit",
476 - }) +
477 - " " +
478 - date.toLocaleTimeString("en-US", {
479 - hour12: false,
480 - hour: "2-digit",
481 - minute: "2-digit",
482 - })
483 - );
479 + return new Intl.DateTimeFormat("en-US", {
480 + month: "2-digit",
481 + day: "2-digit",
482 + hour12,
483 + hour: hour12 ? "numeric" : "2-digit",
484 + minute: "2-digit",
485 + timeZone: getUserTimezone(),
486 + }).format(date);
487 } else {
488 + const hour12 = getUserHour12();
489 // For details: Full format
486 - return (
487 - date.toLocaleDateString("en-US", {
488 - year: "numeric",
489 - month: "long",
490 - day: "numeric",
491 - }) +
492 - " at " +
493 - date.toLocaleTimeString("en-US", {
494 - hour12: true,
495 - hour: "numeric",
496 - minute: "2-digit",
497 - })
498 - );
490 + return new Intl.DateTimeFormat("en-US", {
491 + year: "numeric",
492 + month: "long",
493 + day: "numeric",
494 + hour12,
495 + hour: hour12 ? "numeric" : "2-digit",
496 + minute: "2-digit",
497 + timeZone: getUserTimezone(),
498 + }).format(date);
499 }
500 },
501
@@ -593,7 +593,7 @@ ${memory.content_full}
593 try {
594 const exportData = {
595 memory_subdir: this.selectedMemorySubdir,
596 - export_timestamp: new Date().toISOString(),
596 + export_timestamp: getCurrentUserISOString(),
597 total_memories: this.memories.length,
598 search_query: this.searchQuery,
599 area_filter: this.areaFilter,
@@ -613,7 +613,7 @@ ${memory.content_full}
613 const a = document.createElement("a");
614 a.href = url;
615 a.download = `memory-export-${this.selectedMemorySubdir}-${
616 - new Date().toISOString().split("T")[0]
616 + getCurrentUserDateString()
617 }.json`;
618 document.body.appendChild(a);
619 a.click();
plugins/_office/helpers/document_store.py
+2 -1
@@ -16,6 +16,7 @@ from typing import Any
16 from xml.sax.saxutils import escape
17
18 from helpers import files
19 +from helpers.localization import Localization
20 from plugins._office.helpers import pptx_writer
21
22
@@ -52,7 +53,7 @@ def now() -> float:
53
54
55 def now_iso() -> str:
55 - return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
56 + return Localization.get().now_iso(timespec="seconds")
57
58
59 def ensure_dirs() -> None:
plugins/_office/helpers/libreoffice_desktop.py
+1
@@ -3,3 +3,4 @@ from __future__ import annotations
3 # Compatibility facade for pre-split callers. New Desktop runtime ownership
4 # lives in plugins._desktop.helpers.desktop_session.
5 from plugins._desktop.helpers.desktop_session import * # noqa: F401,F403
6 +from plugins._desktop.helpers.desktop_session import DesktopSessionManager as LibreOfficeDesktopManager
plugins/_office/hooks.py
+14
@@ -138,6 +138,20 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
138 }
139
140
141 +def timezone_changed(timezone: str, previous_timezone: str | None = None) -> dict[str, Any]:
142 + try:
143 + from plugins._office.helpers import libreoffice_desktop
144 +
145 + return libreoffice_desktop.get_manager().sync_timezone(timezone)
146 + except Exception as exc:
147 + return {
148 + "ok": False,
149 + "error": str(exc),
150 + "timezone": timezone,
151 + "previous_timezone": previous_timezone,
152 + }
153 +
154 +
155 def retire_collabora_web_runtime(force: bool = False) -> dict[str, Any]:
156 """Retire the legacy Collabora web runtime without preparing LibreOffice.
157
plugins/_office/webui/office-store.js
+2 -1
@@ -2,6 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
4 import { open as openSurface } from "/js/surfaces.js";
5 +import { getCurrentUserDateString } from "/js/time-utils.js";
6
7 const SAVE_MESSAGE_MS = 1800;
8 const DESKTOP_DOCUMENT_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
@@ -185,7 +186,7 @@ const model = {
186 },
187
188 defaultTitle(kind, fmt) {
188 - const date = new Date().toISOString().slice(0, 10);
189 + const date = getCurrentUserDateString();
190 if (fmt === "md") return `Document ${date}`;
191 if (fmt === "odt") return `Writer ${date}`;
192 if (fmt === "docx") return `DOCX ${date}`;
plugins/_plugin_installer/helpers/install.py
+6 -3
@@ -1,10 +1,9 @@
1 from __future__ import annotations
2
3 -from datetime import datetime, timezone
3 +from datetime import datetime
4 import json
5 import os
6 import time
7 -from turtle import stamp
7 import urllib.request
8 import uuid
9 import zipfile
@@ -12,6 +11,7 @@ from pathlib import Path
11 from typing import Any
12
13 from helpers import files, print_style, plugins, git
14 +from helpers.localization import Localization
15 from helpers import yaml as yaml_helper
16 from helpers.plugins import (
17 META_FILE_NAME,
@@ -272,7 +272,10 @@ def update_from_git(plugin_name: str) -> dict:
272 "title": meta.title if meta else plugin_name,
273 "path": files.deabsolute_path(plugin_dir),
274 "current_commit": head.hexsha,
275 - "current_commit_timestamp": datetime.fromtimestamp(head.committed_date, timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
275 + "current_commit_timestamp": datetime.fromtimestamp(
276 + head.committed_date,
277 + tz=Localization.get().get_tzinfo(),
278 + ).strftime("%Y-%m-%d %H:%M:%S %Z"),
279 "version": getattr(meta, "version", "") or "",
280 "branch": repo.active_branch.name if not repo.head.is_detached else "",
281 "remote_url": git.strip_auth_from_url(repo.remotes.origin.url) if repo.remotes else "",
plugins/_plugin_installer/webui/pluginInstallStore.js
+2 -8
@@ -4,6 +4,7 @@ import { openModal } from "/js/modals.js";
4 import { renderSafeMarkdown } from "/js/safe-markdown.js";
5 import { toastFrontendSuccess, toastFrontendError } from "/components/notifications/notification-store.js";
6 import { showConfirmDialog } from "/js/confirmDialog.js";
7 +import { formatDateTime } from "/js/time-utils.js";
8 import { store as imageViewerStore } from "/components/modals/image-viewer/image-viewer-store.js";
9 import { store as pluginListStore } from "/components/plugins/list/pluginListStore.js";
10 import { store as pluginExecuteStore } from "/components/plugins/list/plugin-execute-store.js";
@@ -776,14 +777,7 @@ const model = {
777 const date = new Date(normalizedValue);
778 if (Number.isNaN(date.getTime())) return value;
779
779 - return new Intl.DateTimeFormat(undefined, {
780 - year: "numeric",
781 - month: "2-digit",
782 - day: "2-digit",
783 - hour: "2-digit",
784 - minute: "2-digit",
785 - second: "2-digit",
786 - }).format(date);
780 + return formatDateTime(normalizedValue, "full");
781 },
782
783 getRepoCommitUrl(plugin, commitHash) {
plugins/_plugin_scan/webui/plugin-scan-store.js
+2 -1
@@ -2,6 +2,7 @@ import { marked } from "/vendor/marked/marked.esm.js";
2 import { createStore } from "/js/AlpineStore.js";
3 import * as api from "/js/api.js";
4 import { openModal } from "/js/modals.js";
5 +import { getUserTimezone } from "/js/time-utils.js";
6 import { toastFrontendError } from "/components/notifications/notification-store.js";
7
8 const BASE = "/plugins/_plugin_scan/webui";
@@ -225,7 +226,7 @@ export const store = createStore("pluginScan", {
226 try {
227 const snap = await api.callJsonApi("/poll", {
228 context: ctxId, log_from: 0, notifications_from: 0,
228 - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
229 + timezone: getUserTimezone(),
230 });
231
232 if (gen === _pollGen && snap.logs?.length) {
plugins/_plugin_validator/webui/plugin-validator-store.js
+2 -1
@@ -2,6 +2,7 @@ import { marked } from "/vendor/marked/marked.esm.js";
2 import { createStore } from "/js/AlpineStore.js";
3 import * as api from "/js/api.js";
4 import { openModal as openAppModal } from "/js/modals.js";
5 +import { getUserTimezone } from "/js/time-utils.js";
6 import { toastFrontendError } from "/components/notifications/notification-store.js";
7
8 const BASE = "/plugins/_plugin_validator/webui";
@@ -440,7 +441,7 @@ export const store = createStore("pluginValidator", {
441 context: ctxId,
442 log_from: 0,
443 notifications_from: 0,
443 - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
444 + timezone: getUserTimezone(),
445 });
446
447 if (gen === _pollGen && snapshot.logs?.length) {
plugins/_time_travel/helpers/time_travel.py
+4 -4
@@ -11,11 +11,11 @@ import subprocess
11 import threading
12 import time
13 from dataclasses import dataclass
14 -from datetime import datetime, timezone
14 from pathlib import Path
15 from typing import Any, Iterable
16
17 from helpers import files
18 +from helpers.localization import Localization
19 from helpers.print_style import PrintStyle
20
21
@@ -166,7 +166,7 @@ class SnapshotResult:
166
167
168 def now_iso() -> str:
169 - return datetime.now(timezone.utc).isoformat()
169 + return Localization.get().now_iso()
170
171
172 def normalize_display_path(path: str) -> str:
@@ -1026,7 +1026,7 @@ class TimeTravelService:
1026 return self._git("show", "-s", "--format=%T", commit_hash).stdout.strip()
1027
1028 def _preserve_ref(self, commit_hash: str, *, reason: str) -> str:
1029 - stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
1029 + stamp = Localization.get().now().strftime("%Y%m%d%H%M%S")
1030 base_ref = f"{PRESERVED_REF_PREFIX}/{stamp}-{reason}-{commit_hash[:12]}"
1031 ref = base_ref
1032 counter = 2
@@ -1174,7 +1174,7 @@ class TimeTravelService:
1174 return "refs/heads/" + refs[0].relative_to(heads_dir).as_posix()
1175
1176 def _next_invalid_repo_backup_path(self) -> Path:
1177 - stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
1177 + stamp = Localization.get().now().strftime("%Y%m%d%H%M%S")
1178 base_path = self.workspace.shadow_path / f"{SHADOW_REPO_BACKUP_PREFIX}-{stamp}"
1179 backup_path = base_path
1180 counter = 2
plugins/_time_travel/webui/time-travel-store.js
+2 -6
@@ -2,6 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { getContext } from "/index.js";
4 import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 +import { formatDateTime } from "/js/time-utils.js";
6
7 const REFRESH_DEBOUNCE_MS = 180;
8
@@ -392,12 +393,7 @@ const model = {
393 if (!value) return "";
394 const date = new Date(value);
395 if (Number.isNaN(date.getTime())) return String(value);
395 - return date.toLocaleString(undefined, {
396 - month: "short",
397 - day: "numeric",
398 - hour: "2-digit",
399 - minute: "2-digit",
400 - });
396 + return formatDateTime(value, "short");
397 },
398
399 formatSigned(value, sign) {
run_ui.py
+1 -3
@@ -5,10 +5,8 @@ from helpers.server_startup import run_uvicorn_with_retries
5 from helpers.ui_server import UiServerRuntime, configure_process_environment
6
7
8 -configure_process_environment()
9 -
10 -
8 def run():
9 + configure_process_environment()
10 PrintStyle().print("Initializing Python framework...")
11 PrintStyle().print("Checking for data migration...")
12 run_migration_checks()
tests/test_settings_developer_sections.py
+23
@@ -28,3 +28,26 @@ def test_websocket_harness_template_is_gated_by_runtime():
28 content = template_path.read_text(encoding="utf-8")
29 assert "window.runtimeInfo?.isDevelopment" in content
30 assert "$store.root?.isDevelopment" not in content
31 +
32 +
33 +def test_timezone_settings_section_is_present():
34 + store_path = PROJECT_ROOT / "webui" / "components" / "settings" / "settings-store.js"
35 + agent_settings_path = PROJECT_ROOT / "webui" / "components" / "settings" / "agent" / "agent-settings.html"
36 + locale_path = PROJECT_ROOT / "webui" / "components" / "settings" / "agent" / "locale.html"
37 +
38 + store = store_path.read_text(encoding="utf-8")
39 + agent_settings = agent_settings_path.read_text(encoding="utf-8")
40 + assert "section-locale" in store
41 + assert "section-voice" in store
42 + assert "settings/agent/locale.html" in agent_settings
43 + assert store.index("section-models-summary") < store.index("section-locale")
44 + assert store.index("section-locale") < store.index("section-agent-plugins")
45 + assert agent_settings.index("section-models-summary") < agent_settings.index("section-locale")
46 + assert agent_settings.index("section-locale") < agent_settings.index("section-agent-plugins")
47 + assert agent_settings.rindex("section-locale") < agent_settings.rindex("section-agent-plugins")
48 + locale = locale_path.read_text(encoding="utf-8")
49 + assert "Automatic (browser)" in locale
50 + assert "$store.settings.settings.timezone" in locale
51 + assert "12-hour (AM/PM)" in locale
52 + assert "24-hour" in locale
53 + assert "$store.settings.settings.time_format" in locale
tests/test_timezone_regressions.py new
+348
@@ -0,0 +1,348 @@
1 +import os
2 +import sys
3 +import threading
4 +from datetime import datetime
5 +from pathlib import Path
6 +
7 +import pytest
8 +import pytz
9 +from langchain_core.documents import Document
10 +
11 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
12 +if str(PROJECT_ROOT) not in sys.path:
13 + sys.path.insert(0, str(PROJECT_ROOT))
14 +
15 +import helpers.localization as localization_module
16 +import helpers.plugins as plugins_module
17 +import helpers.settings as settings_module
18 +from helpers.localization import Localization
19 +from helpers.task_scheduler import (
20 + TaskPlan,
21 + parse_task_plan,
22 + serialize_task_plan,
23 +)
24 +from plugins._memory.api.memory_dashboard import MemoryDashboard
25 +from plugins._office.helpers import libreoffice_desktop
26 +
27 +
28 +@pytest.fixture
29 +def isolated_localization(monkeypatch):
30 + saved: list[tuple[str, str]] = []
31 +
32 + def fake_get_dotenv_value(key, default=None):
33 + if key == "DEFAULT_USER_TIMEZONE":
34 + return default or "UTC"
35 + if key == "DEFAULT_USER_UTC_OFFSET_MINUTES":
36 + return None
37 + return default
38 +
39 + monkeypatch.setattr(localization_module, "get_dotenv_value", fake_get_dotenv_value)
40 + monkeypatch.setattr(
41 + localization_module,
42 + "save_dotenv_value",
43 + lambda key, value: saved.append((key, str(value))),
44 + )
45 + monkeypatch.setattr(
46 + localization_module.PrintStyle,
47 + "error",
48 + staticmethod(lambda *args, **kwargs: None),
49 + )
50 + Localization._instance = None
51 + original_tz = os.environ.get("TZ")
52 +
53 + yield saved
54 +
55 + Localization._instance = None
56 + if original_tz is None:
57 + monkeypatch.delenv("TZ", raising=False)
58 + else:
59 + monkeypatch.setenv("TZ", original_tz)
60 +
61 +
62 +def set_test_timezone(timezone: str) -> Localization:
63 + Localization._instance = Localization(timezone)
64 + return Localization.get()
65 +
66 +
67 +def test_invalid_timezone_preserves_current_user_timezone(isolated_localization):
68 + saved = isolated_localization
69 + localization = set_test_timezone("Europe/Rome")
70 + saved.clear()
71 +
72 + localization.set_timezone("Mars/Olympus")
73 +
74 + assert localization.get_timezone() == "Europe/Rome"
75 + assert os.environ["TZ"] == "Europe/Rome"
76 + assert not any(item == ("DEFAULT_USER_TIMEZONE", "UTC") for item in saved)
77 + assert not any(item == ("DEFAULT_USER_TIMEZONE", "Mars/Olympus") for item in saved)
78 +
79 +
80 +def test_startup_refreshes_stale_persisted_offset(monkeypatch):
81 + saved: list[tuple[str, str]] = []
82 +
83 + def fake_get_dotenv_value(key, default=None):
84 + if key == "DEFAULT_USER_TIMEZONE":
85 + return "Europe/Rome"
86 + if key == "DEFAULT_USER_UTC_OFFSET_MINUTES":
87 + return "0"
88 + return default
89 +
90 + monkeypatch.setattr(localization_module, "get_dotenv_value", fake_get_dotenv_value)
91 + monkeypatch.setattr(
92 + localization_module,
93 + "save_dotenv_value",
94 + lambda key, value: saved.append((key, str(value))),
95 + )
96 + Localization._instance = None
97 +
98 + localization = Localization.get()
99 + expected_offset = int(
100 + datetime.now(pytz.timezone("Europe/Rome")).utcoffset().total_seconds() // 60
101 + )
102 +
103 + assert localization.get_timezone() == "Europe/Rome"
104 + assert localization.get_offset_minutes() == expected_offset
105 + assert ("DEFAULT_USER_UTC_OFFSET_MINUTES", str(expected_offset)) in saved
106 +
107 +
108 +def test_scheduler_naive_plan_times_round_trip_as_user_local(isolated_localization):
109 + set_test_timezone("Europe/Rome")
110 +
111 + plan = parse_task_plan({"todo": ["2026-05-03T09:30:00"], "in_progress": None, "done": []})
112 + serialized = serialize_task_plan(plan)
113 +
114 + assert serialized["todo"] == ["2026-05-03T09:30:00+02:00"]
115 +
116 +
117 +def test_settings_auto_timezone_resolves_to_browser_timezone(isolated_localization, monkeypatch):
118 + set_test_timezone("UTC")
119 + hooks: list[dict] = []
120 + base_settings = settings_module.get_default_settings()
121 + monkeypatch.setattr(
122 + settings_module,
123 + "_settings",
124 + {**base_settings, "timezone": settings_module.TIMEZONE_AUTO},
125 + )
126 + monkeypatch.setattr(
127 + plugins_module,
128 + "call_plugin_hook",
129 + lambda plugin_name, hook_name, *args, **kwargs: hooks.append(
130 + {
131 + "plugin_name": plugin_name,
132 + "hook_name": hook_name,
133 + "kwargs": kwargs,
134 + }
135 + ),
136 + )
137 +
138 + settings_module._apply_timezone_setting(
139 + {**base_settings, "timezone": settings_module.TIMEZONE_AUTO},
140 + browser_timezone="Europe/Rome",
141 + )
142 +
143 + assert Localization.get().get_timezone() == "Europe/Rome"
144 + assert hooks[0]["plugin_name"] == "_office"
145 + assert hooks[0]["hook_name"] == "timezone_changed"
146 + assert hooks[0]["kwargs"]["previous_timezone"] == "UTC"
147 + assert hooks[0]["kwargs"]["timezone"] == "Europe/Rome"
148 +
149 +
150 +def test_settings_fixed_timezone_ignores_browser_timezone(isolated_localization, monkeypatch):
151 + set_test_timezone("Europe/Rome")
152 + base_settings = settings_module.get_default_settings()
153 + monkeypatch.setattr(
154 + settings_module,
155 + "_settings",
156 + {**base_settings, "timezone": "America/New_York"},
157 + )
158 + monkeypatch.setattr(plugins_module, "call_plugin_hook", lambda *args, **kwargs: None)
159 +
160 + settings_module._apply_timezone_setting(
161 + {**base_settings, "timezone": settings_module.TIMEZONE_AUTO},
162 + browser_timezone="Europe/Rome",
163 + )
164 +
165 + assert Localization.get().get_timezone() == "America/New_York"
166 +
167 +
168 +def test_settings_fixed_timezone_reapplies_when_runtime_drifted(isolated_localization, monkeypatch):
169 + set_test_timezone("Europe/Rome")
170 + base_settings = settings_module.get_default_settings()
171 + fixed_settings = {**base_settings, "timezone": "America/New_York"}
172 + hooks: list[dict] = []
173 + monkeypatch.setattr(settings_module, "_settings", fixed_settings)
174 + monkeypatch.setattr(
175 + plugins_module,
176 + "call_plugin_hook",
177 + lambda plugin_name, hook_name, *args, **kwargs: hooks.append(
178 + {
179 + "plugin_name": plugin_name,
180 + "hook_name": hook_name,
181 + "kwargs": kwargs,
182 + }
183 + ),
184 + )
185 +
186 + settings_module._apply_timezone_setting(
187 + {**base_settings, "timezone": "America/New_York"},
188 + browser_timezone="Europe/Rome",
189 + )
190 +
191 + assert Localization.get().get_timezone() == "America/New_York"
192 + assert hooks[0]["plugin_name"] == "_office"
193 + assert hooks[0]["hook_name"] == "timezone_changed"
194 + assert hooks[0]["kwargs"]["previous_timezone"] == "Europe/Rome"
195 + assert hooks[0]["kwargs"]["timezone"] == "America/New_York"
196 +
197 +
198 +def test_settings_rejects_invalid_timezone_value():
199 + settings_data = settings_module.get_default_settings()
200 + normalized = settings_module.normalize_settings(
201 + {**settings_data, "timezone": "Mars/Olympus"}
202 + )
203 +
204 + assert normalized["timezone"] == settings_module.TIMEZONE_AUTO
205 +
206 +
207 +def test_settings_rejects_invalid_time_format_value():
208 + settings_data = settings_module.get_default_settings()
209 +
210 + invalid = settings_module.normalize_settings(
211 + {**settings_data, "time_format": "bananas"}
212 + )
213 + twenty_four = settings_module.normalize_settings(
214 + {**settings_data, "time_format": settings_module.TIME_FORMAT_24H}
215 + )
216 +
217 + assert invalid["time_format"] == settings_module.TIME_FORMAT_12H
218 + assert twenty_four["time_format"] == settings_module.TIME_FORMAT_24H
219 +
220 +
221 +def test_scheduler_naive_plan_times_follow_changed_user_timezone(isolated_localization):
222 + set_test_timezone("America/New_York")
223 +
224 + plan = parse_task_plan({"todo": ["2026-05-03T09:30:00"], "in_progress": None, "done": []})
225 + serialized = serialize_task_plan(plan)
226 +
227 + assert serialized["todo"] == ["2026-05-03T09:30:00-04:00"]
228 +
229 +
230 +def test_task_plan_create_localizes_naive_datetimes(isolated_localization):
231 + set_test_timezone("Europe/Rome")
232 +
233 + plan = TaskPlan.create(todo=[datetime(2026, 5, 3, 9, 30)])
234 +
235 + assert plan.todo[0].isoformat() == "2026-05-03T09:30:00+02:00"
236 +
237 +
238 +def test_memory_dashboard_normalizes_legacy_naive_timestamps(isolated_localization):
239 + set_test_timezone("Europe/Rome")
240 + dashboard = MemoryDashboard(app=None, thread_lock=threading.RLock())
241 +
242 + formatted = dashboard._format_memory_for_dashboard(
243 + Document(
244 + page_content="legacy memory",
245 + metadata={
246 + "id": "memory-1",
247 + "area": "main",
248 + "timestamp": "2026-05-02 18:27:51",
249 + },
250 + )
251 + )
252 +
253 + assert formatted["timestamp"] == "2026-05-02T18:27:51+02:00"
254 + assert formatted["metadata"]["timestamp"] == "2026-05-02 18:27:51"
255 +
256 +
257 +def test_memory_dashboard_converts_aware_timestamps_to_user_timezone(isolated_localization):
258 + set_test_timezone("Europe/Rome")
259 + dashboard = MemoryDashboard(app=None, thread_lock=threading.RLock())
260 +
261 + assert (
262 + dashboard._serialize_memory_timestamp("2026-05-02T16:27:51+00:00")
263 + == "2026-05-02T18:27:51+02:00"
264 + )
265 +
266 +
267 +class FakeProcess:
268 + pid = 4242
269 +
270 + def poll(self):
271 + return None
272 +
273 +
274 +def test_desktop_session_env_uses_session_timezone(isolated_localization, tmp_path):
275 + set_test_timezone("Europe/Rome")
276 + session = libreoffice_desktop.DesktopSession(
277 + session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
278 + file_id=libreoffice_desktop.SYSTEM_FILE_ID,
279 + extension="desktop",
280 + path=str(tmp_path),
281 + title="Desktop",
282 + display=120,
283 + xpra_port=14500,
284 + token=libreoffice_desktop.SYSTEM_SESSION_ID,
285 + url="/desktop/session/agent-zero-desktop/index.html",
286 + profile_dir=tmp_path / "profile",
287 + timezone="America/New_York",
288 + )
289 +
290 + env = libreoffice_desktop.LibreOfficeDesktopManager()._session_env(session)
291 +
292 + assert env["TZ"] == "America/New_York"
293 +
294 +
295 +def test_desktop_timezone_sync_restarts_active_system_desktop(
296 + isolated_localization,
297 + monkeypatch,
298 + tmp_path,
299 +):
300 + set_test_timezone("America/New_York")
301 + manager = libreoffice_desktop.LibreOfficeDesktopManager()
302 + old_session = libreoffice_desktop.DesktopSession(
303 + session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
304 + file_id=libreoffice_desktop.SYSTEM_FILE_ID,
305 + extension="desktop",
306 + path=str(tmp_path),
307 + title="Desktop",
308 + display=120,
309 + xpra_port=14500,
310 + token=libreoffice_desktop.SYSTEM_SESSION_ID,
311 + url="/desktop/session/agent-zero-desktop/index.html",
312 + profile_dir=tmp_path / "profile-old",
313 + timezone="Europe/Rome",
314 + processes={"xpra": FakeProcess()},
315 + )
316 + replacement = libreoffice_desktop.DesktopSession(
317 + session_id=libreoffice_desktop.SYSTEM_SESSION_ID,
318 + file_id=libreoffice_desktop.SYSTEM_FILE_ID,
319 + extension="desktop",
320 + path=str(tmp_path),
321 + title="Desktop",
322 + display=120,
323 + xpra_port=14500,
324 + token=libreoffice_desktop.SYSTEM_SESSION_ID,
325 + url="/desktop/session/agent-zero-desktop/index.html",
326 + profile_dir=tmp_path / "profile-new",
327 + timezone="America/New_York",
328 + processes={"xpra": FakeProcess()},
329 + )
330 + manager._sessions[libreoffice_desktop.SYSTEM_SESSION_ID] = old_session
331 + restarted: list[libreoffice_desktop.DesktopSession] = []
332 +
333 + def fake_restart(session):
334 + restarted.append(session)
335 + manager._sessions[libreoffice_desktop.SYSTEM_SESSION_ID] = replacement
336 + return replacement
337 +
338 + monkeypatch.setattr(manager, "_restart_system_desktop_for_timezone_locked", fake_restart)
339 +
340 + result = manager.sync_timezone("America/New_York")
341 +
342 + assert result == {
343 + "ok": True,
344 + "restarted": True,
345 + "session_id": libreoffice_desktop.SYSTEM_SESSION_ID,
346 + "timezone": "America/New_York",
347 + }
348 + assert restarted == [old_session]
tools/wait.py
+5 -5
@@ -1,5 +1,5 @@
1 import asyncio
2 -from datetime import datetime, timedelta, timezone
2 +from datetime import timedelta
3 from helpers.tool import Tool, Response
4 from helpers.print_style import PrintStyle
5 from helpers.wait import managed_wait
@@ -18,7 +18,7 @@ class WaitTool(Tool):
18
19 is_duration_wait = not bool(until_timestamp_str)
20
21 - now = datetime.now(timezone.utc)
21 + now = Localization.get().now()
22 target_time = None
23
24 if until_timestamp_str:
@@ -47,11 +47,11 @@ class WaitTool(Tool):
47
48 if target_time <= now:
49 return Response(
50 - message=f"Target time {target_time.isoformat()} is in the past.",
50 + message=f"Target time {Localization.get().serialize_datetime(target_time)} is in the past.",
51 break_loop=False,
52 )
53
54 - PrintStyle.info(f"Waiting until {target_time.isoformat()}...")
54 + PrintStyle.info(f"Waiting until {Localization.get().serialize_datetime(target_time)}...")
55
56 target_time = await managed_wait(
57 agent=self.agent,
@@ -66,7 +66,7 @@ class WaitTool(Tool):
66
67 message = self.agent.read_prompt(
68 "fw.wait_complete.md",
69 - target_time=target_time.isoformat()
69 + target_time=Localization.get().serialize_datetime(target_time)
70 )
71
72 return Response(
webui/components/chat/input/composer-banner-store.js
+2 -1
@@ -1,5 +1,6 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 +import { getCurrentUserISOString } from "/js/time-utils.js";
4
5 function buildBannersContext() {
6 return {
@@ -8,7 +9,7 @@ function buildBannersContext() {
9 hostname: window.location.hostname,
10 port: window.location.port,
11 browser: navigator.userAgent,
11 - timestamp: new Date().toISOString(),
12 + timestamp: getCurrentUserISOString(),
13 };
14 }
15
webui/components/modals/file-browser/file-browser-store.js
+2 -8
@@ -1,5 +1,6 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import { fetchApi } from "/js/api.js";
3 +import { formatDateTime } from "/js/time-utils.js";
4 import { store as fileEditorStore } from "/components/modals/file-editor/file-editor-store.js";
5
6 // Model migrated from legacy file_browser.js (lift-and-shift)
@@ -134,14 +135,7 @@ const model = {
135 },
136
137 formatDate(dateString) {
137 - const options = {
138 - year: "numeric",
139 - month: "short",
140 - day: "numeric",
141 - hour: "2-digit",
142 - minute: "2-digit",
143 - };
144 - return new Date(dateString).toLocaleDateString(undefined, options);
138 + return formatDateTime(dateString, "short");
139 },
140
141 decorateEntries(entries = [], selectedPaths = new Set()) {
webui/components/modals/process-step-detail/step-detail-store.js
+2 -1
@@ -1,6 +1,7 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import { createActionButton, copyToClipboard } from "/components/messages/action-buttons/simple-action-buttons.js";
3 import { store as notificationStore } from "/components/notifications/notification-store.js";
4 +import { formatDateTime } from "/js/time-utils.js";
5
6 // Step Detail Store - manages the step detail modal
7
@@ -62,7 +63,7 @@ const model = {
63 if (heading) lines.push(`Heading: ${heading}`);
64 if (step.timestamp) {
65 const date = new Date(parseFloat(step.timestamp) * 1000);
65 - lines.push(`Timestamp: ${date.toISOString()}`);
66 + lines.push(`Timestamp: ${formatDateTime(date.toISOString(), "full")}`);
67 }
68 if (step.durationMs) lines.push(`Duration: ${step.durationMs}ms`);
69 if (step.kvps) {
webui/components/modals/scheduler/scheduler-store.js
+15 -7
@@ -1,6 +1,12 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import { fetchApi } from "/js/api.js";
3 -import { formatDateTime, getUserTimezone } from "/js/time-utils.js";
3 +import {
4 + formatDateTime,
5 + getUserDateTimeParts,
6 + getUserTimezone,
7 + toUserISOString,
8 + toUserWallClockISOString,
9 +} from "/js/time-utils.js";
10 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
11 import { store as projectsStore } from "/components/projects/projects-store.js";
12 import { store as notificationsStore } from "/components/notifications/notification-store.js";
@@ -175,18 +181,18 @@ function normalizePlanStruct(plan) {
181 const sanitized = clone.todo
182 .map((value) => new Date(value))
183 .filter((date) => !Number.isNaN(date.getTime()))
178 - .map((date) => date.toISOString())
184 + .map((date) => toUserISOString(date))
185 .sort();
186 clone.todo = sanitized;
187 clone.done = clone.done
188 .map((value) => new Date(value))
189 .filter((date) => !Number.isNaN(date.getTime()))
184 - .map((date) => date.toISOString());
190 + .map((date) => toUserISOString(date));
191 if (clone.in_progress) {
192 const inProgress = new Date(clone.in_progress);
193 clone.in_progress = Number.isNaN(inProgress.getTime())
194 ? null
189 - : inProgress.toISOString();
195 + : toUserISOString(inProgress);
196 }
197 return clone;
198 }
@@ -382,6 +388,8 @@ function setupPlannerInput(inputId) {
388 wrapper.appendChild(input);
389 input.classList.add("scheduler-flatpickr-input");
390
391 + const nowParts = getUserDateTimeParts();
392 + const roundedMinute = Math.ceil(nowParts.minute / 5) * 5;
393 const options = {
394 dateFormat: "Y-m-d H:i",
395 enableTime: true,
@@ -392,8 +400,8 @@ function setupPlannerInput(inputId) {
400 positionElement: wrapper,
401 theme: "scheduler-theme",
402 minuteIncrement: 5,
395 - defaultHour: new Date().getHours(),
396 - defaultMinute: Math.ceil(new Date().getMinutes() / 5) * 5,
403 + defaultHour: roundedMinute >= 60 ? (nowParts.hour + 1) % 24 : nowParts.hour,
404 + defaultMinute: roundedMinute % 60,
405 onOpen(selectedDates, dateStr, instance) {
406 instance.calendarContainer.style.zIndex = "9999";
407 instance.calendarContainer.style.position = "absolute";
@@ -1073,7 +1081,7 @@ const schedulerStoreModel = {
1081 return;
1082 }
1083
1076 - this.editingTask.plan.todo.push(selectedDate.toISOString());
1084 + this.editingTask.plan.todo.push(toUserWallClockISOString(selectedDate));
1085 this.editingTask.plan.todo.sort();
1086
1087 if (input._flatpickr) {
webui/components/notifications/notification-store.js
+3 -2
@@ -1,6 +1,7 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import * as API from "/js/api.js";
3 import { openModal } from "/js/modals.js";
4 +import { formatDateTime, getCurrentUserISOString } from "/js/time-utils.js";
5
6 export const NotificationType = {
7 INFO: "info",
@@ -408,7 +409,7 @@ const model = {
409 else if (diffHours < 24) return `${Math.round(diffHours)}h ago`;
410 else if (diffDays < 7) return `${Math.round(diffDays)}d ago`;
411
411 - return date.toLocaleDateString();
412 + return formatDateTime(timestamp, "date");
413 },
414
415 // Get CSS class for notification type
@@ -615,7 +616,7 @@ const model = {
616 group = "",
617 priority = defaultPriority
618 ) {
618 - const timestamp = new Date().toISOString();
619 + const timestamp = getCurrentUserISOString();
620 const notification = {
621 id: `frontend-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
622 type: type,
webui/components/plugins/list/plugin-execute-modal.html
+1 -1
@@ -21,7 +21,7 @@
21 <template x-if="$store.pluginExecuteStore.lastExecution">
22 <span class="last-exec-info">
23 Last run:
24 - <span x-text="new Date($store.pluginExecuteStore.lastExecution.executed_at).toLocaleString()"></span>
24 + <span x-text="$store.pluginExecuteStore.formatTimestamp($store.pluginExecuteStore.lastExecution.executed_at)"></span>
25 &nbsp;&mdash;&nbsp;
26 <span :class="$store.pluginExecuteStore.lastExecution.exit_code === 0 ? 'exit-ok' : 'exit-err'"
27 x-text="$store.pluginExecuteStore.lastExecution.exit_code === 0 ? 'succeeded' : 'failed (exit ' + $store.pluginExecuteStore.lastExecution.exit_code + ')'">
webui/components/plugins/list/plugin-execute-store.js
+6
@@ -4,6 +4,7 @@ import {
4 store as notificationStore,
5 defaultPriority,
6 } from "/components/notifications/notification-store.js";
7 +import { formatDateTime } from "/js/time-utils.js";
8
9 const model = {
10 pluginName: "",
@@ -78,6 +79,11 @@ const model = {
79 this.exitCode = null;
80 this.lastExecution = null;
81 },
82 +
83 + formatTimestamp(value) {
84 + if (!value) return "";
85 + return formatDateTime(value, "full");
86 + },
87 };
88
89 export const store = createStore("pluginExecuteStore", model);
webui/components/settings/agent/agent-settings.html
+16
@@ -33,6 +33,18 @@
33 <span>Workdir</span>
34 </a>
35 </li>
36 + <li>
37 + <a href="#section-locale">
38 + <span class="material-symbols-outlined" aria-hidden="true">language</span>
39 + <span>Locale</span>
40 + </a>
41 + </li>
42 + <li>
43 + <a href="#section-agent-plugins">
44 + <span class="material-symbols-outlined" aria-hidden="true">extension</span>
45 + <span>Plugins</span>
46 + </a>
47 + </li>
48 </ul>
49 </nav>
50
@@ -52,6 +64,10 @@
64 <x-component path="settings/agent/workdir.html"></x-component>
65 </div>
66
67 + <div id="section-locale" class="section">
68 + <x-component path="settings/agent/locale.html"></x-component>
69 + </div>
70 +
71 <!-- Plugin settings subsection: shows plugins tagged with "agent" -->
72 <div id="section-agent-plugins" class="section">
73 <x-component path="settings/plugins/plugins-subsection.html" data-tab="agent"></x-component>
webui/components/settings/agent/locale.html new
+58
@@ -0,0 +1,58 @@
1 +<html>
2 + <head>
3 + <title>Locale</title>
4 + </head>
5 +
6 + <body>
7 + <div x-data>
8 + <template x-if="$store.settings.settings">
9 + <div>
10 + <div class="section-title">Locale</div>
11 + <div class="section-description">
12 + Control how dates and times appear across Agent Zero.
13 + </div>
14 +
15 + <div class="field">
16 + <div class="field-label">
17 + <div class="field-title">Timezone</div>
18 + <div class="field-description">
19 + Effective timezone: <span x-text="$store.settings.effectiveTimezone"></span>
20 + </div>
21 + </div>
22 + <div class="field-control">
23 + <select
24 + :value="$store.settings.settings.timezone || 'auto'"
25 + @change="$store.settings.settings.timezone = $event.target.value"
26 + x-effect="$nextTick(() => { $el.value = $store.settings.settings.timezone || 'auto'; })"
27 + >
28 + <option value="auto">Automatic (browser)</option>
29 + <template x-for="option in $store.settings.additional?.timezones || []" :key="option.value">
30 + <option :value="option.value" x-text="option.label"></option>
31 + </template>
32 + </select>
33 + </div>
34 + </div>
35 +
36 + <div class="field">
37 + <div class="field-label">
38 + <div class="field-title">Time format</div>
39 + <div class="field-description">
40 + Choose the clock style used for displayed times.
41 + </div>
42 + </div>
43 + <div class="field-control">
44 + <select
45 + :value="$store.settings.settings.time_format || '12h'"
46 + @change="$store.settings.settings.time_format = $event.target.value"
47 + x-effect="$nextTick(() => { $el.value = $store.settings.settings.time_format || '12h'; })"
48 + >
49 + <option value="12h">12-hour (AM/PM)</option>
50 + <option value="24h">24-hour</option>
51 + </select>
52 + </div>
53 + </div>
54 + </div>
55 + </template>
56 + </div>
57 + </body>
58 +</html>
webui/components/settings/backup/backup-store.js
+16 -5
@@ -1,4 +1,11 @@
1 import { createStore } from "/js/AlpineStore.js";
2 +import {
3 + formatDateTime,
4 + getCurrentUserDateString,
5 + getCurrentUserISOString,
6 + getUserHour12,
7 + getUserTimezone,
8 +} from "/js/time-utils.js";
9
10 // Global function references
11 const sendJsonData = globalThis.sendJsonData;
@@ -70,7 +77,11 @@ const model = {
77
78 // File operations logging
79 addFileOperation(message) {
73 - const timestamp = new Date().toLocaleTimeString();
80 + const timestamp = new Intl.DateTimeFormat(undefined, {
81 + timeStyle: "medium",
82 + hour12: getUserHour12(),
83 + timeZone: getUserTimezone(),
84 + }).format(new Date());
85 this.fileOperationsLog += `[${timestamp}] ${message}\n`;
86
87 // Auto-scroll to bottom - use setTimeout since $nextTick is not available in stores
@@ -117,7 +128,7 @@ const model = {
128
129 // Get default backup metadata with resolved patterns from backend
130 async getDefaultBackupMetadata() {
120 - const timestamp = new Date().toISOString();
131 + const timestamp = getCurrentUserISOString();
132
133 try {
134 // Get resolved default patterns from backend
@@ -129,7 +140,7 @@ const model = {
140 const exclude_patterns = response.default_patterns.exclude_patterns;
141
142 return {
132 - backup_name: `agent-zero-backup-${timestamp.slice(0, 10)}`,
143 + backup_name: `agent-zero-backup-${getCurrentUserDateString()}`,
144 include_hidden: true,
145 include_patterns: include_patterns,
146 exclude_patterns: exclude_patterns,
@@ -841,7 +852,7 @@ const model = {
852 // Utility
853 formatTimestamp(timestamp) {
854 if (!timestamp) return 'Unknown';
844 - return new Date(timestamp).toLocaleString();
855 + return formatDateTime(timestamp, "full");
856 },
857
858 formatFileSize(bytes) {
@@ -853,7 +864,7 @@ const model = {
864
865 formatDate(dateString) {
866 if (!dateString) return 'Unknown';
856 - return new Date(dateString).toLocaleDateString();
867 + return formatDateTime(dateString, "date");
868 }
869 };
870
webui/components/settings/developer/websocket-event-console-store.js
+3 -2
@@ -1,6 +1,7 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import { getNamespacedClient } from "/js/websocket.js";
3 import { store as notificationStore } from "/components/notifications/notification-store.js";
4 +import { getCurrentUserISOString } from "/js/time-utils.js";
5
6 const websocket = getNamespacedClient("/ws");
7 websocket.addHandlers(["ws_dev_test"]);
@@ -112,7 +113,7 @@ const model = {
113
114 try {
115 await websocket.request(SUBSCRIBE_EVENT, {
115 - requestedAt: new Date().toISOString(),
116 + requestedAt: getCurrentUserISOString(),
117 });
118 this.subscriptionActive = true;
119 this.lastError = null;
@@ -205,7 +206,7 @@ const model = {
206 eventId: envelope?.eventId || null,
207 sid: payload.sid || null,
208 correlationId: payload.correlationId || envelope?.correlationId || null,
208 - timestamp: payload.timestamp || envelope?.ts || new Date().toISOString(),
209 + timestamp: payload.timestamp || envelope?.ts || getCurrentUserISOString(),
210 handlerId: payload.handlerId || envelope?.handlerId || "WsManager",
211 resultSummary: payload.resultSummary || {},
212 payloadSummary: payload.payloadSummary || {},
webui/components/settings/developer/websocket-test-store.js
+3 -2
@@ -7,6 +7,7 @@ import {
7 import { store as notificationStore } from "/components/notifications/notification-store.js";
8 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
9 import { store as syncStore } from "/components/sync/sync-store.js";
10 +import { getCurrentUserISOString, getUserTimezone } from "/js/time-utils.js";
11
12 const MAX_PAYLOAD_BYTES = 50 * 1024 * 1024;
13 const TOAST_DURATION = 5;
@@ -16,7 +17,7 @@ websocket.addHandlers(["ws_dev_test"]);
17 const stateSocket = websocket; // same /ws namespace client
18
19 function now() {
19 - return new Date().toISOString();
20 + return getCurrentUserISOString();
21 }
22
23 function payloadSize(value) {
@@ -459,7 +460,7 @@ const model = {
460 await this.ensureSubscribed("state_push", true);
461 this.appendLog("Subscribed to state_push.");
462
462 - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
463 + const timezone = getUserTimezone();
464 const response = await stateSocket.request(
465 "state_request",
466 {
webui/components/settings/external/self-update-store.js
+2 -1
@@ -2,6 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
2 import * as API from "/js/api.js";
3 import { store as notificationStore } from "/components/notifications/notification-store.js";
4 import { openModal, closeModal } from "/js/modals.js";
5 +import { formatDateTime } from "/js/time-utils.js";
6
7 const HEALTH_POLL_INTERVAL_MS = 2000;
8 const HEALTH_WAIT_BUFFER_MS = 30000;
@@ -332,7 +333,7 @@ const model = {
333 formatTimestamp(value) {
334 if (!value) return "";
335 try {
335 - return new Date(value).toLocaleString();
336 + return formatDateTime(value, "full");
337 } catch {
338 return value;
339 }
webui/components/settings/settings-store.js
+37 -2
@@ -1,6 +1,11 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import * as API from "/js/api.js";
3 import { store as notificationStore } from "/components/notifications/notification-store.js";
4 +import {
5 + getBrowserTimezone,
6 + setConfiguredTimeFormat,
7 + setConfiguredTimezone,
8 +} from "/js/time-utils.js";
9
10 // Constants
11 const VIEW_MODE_STORAGE_KEY = "settingsActiveTab";
@@ -17,8 +22,9 @@ const TAB_ITEMS = Object.freeze([
22 sections: [
23 { id: "section-agent-config", label: "Agent Config", icon: "settings" },
24 { id: "section-models-summary", label: "Models", icon: "forum" },
20 - { id: "section-speech", label: "Speech", icon: "mic" },
25 + { id: "section-voice", label: "Voice", icon: "mic" },
26 { id: "section-workdir", label: "Workdir", icon: "folder" },
27 + { id: "section-locale", label: "Locale", icon: "language" },
28 { id: "section-agent-plugins", label: "Plugins", icon: "extension" },
29 ],
30 },
@@ -135,6 +141,7 @@ const model = {
141 if (response && response.settings) {
142 this.settings = response.settings;
143 this.additional = response.additional || null;
144 + this.applyLocaleRuntime(this.settings);
145 } else {
146 throw new Error("Invalid settings response");
147 }
@@ -210,6 +217,30 @@ const model = {
217 return tab?.sections?.[0]?.id || null;
218 },
219
220 + get browserTimezone() {
221 + return getBrowserTimezone();
222 + },
223 +
224 + get effectiveTimezone() {
225 + if (!this.settings) return this.browserTimezone;
226 + return this.settings.timezone === "auto"
227 + ? this.browserTimezone
228 + : this.settings.timezone || this.browserTimezone;
229 + },
230 +
231 + applyTimezoneRuntime(timezone) {
232 + setConfiguredTimezone(timezone || "auto");
233 + },
234 +
235 + applyTimeFormatRuntime(timeFormat) {
236 + setConfiguredTimeFormat(timeFormat || "12h");
237 + },
238 +
239 + applyLocaleRuntime(settings) {
240 + this.applyTimezoneRuntime(settings?.timezone);
241 + this.applyTimeFormatRuntime(settings?.time_format);
242 + },
243 +
244 getTabIdForSection(sectionId) {
245 if (!sectionId) return null;
246 const tab = TAB_ITEMS.find((item) =>
@@ -476,10 +507,14 @@ const model = {
507
508 this.isLoading = true;
509 try {
479 - const response = await API.callJsonApi("settings_set", { settings: this.settings });
510 + const response = await API.callJsonApi("settings_set", {
511 + settings: this.settings,
512 + browser_timezone: this.browserTimezone,
513 + });
514 if (response && response.settings) {
515 this.settings = response.settings;
516 this.additional = response.additional || this.additional;
517 + this.applyLocaleRuntime(this.settings);
518 toast("Settings saved successfully", "success");
519 document.dispatchEvent(
520 new CustomEvent("settings-updated", { detail: response.settings })
webui/components/welcome/welcome-store.js
+2 -1
@@ -5,6 +5,7 @@ import { store as memoryStore } from "/plugins/_memory/webui/memory-dashboard-st
5 import { store as projectsStore } from "/components/projects/projects-store.js";
6 import { store as chatInputStore } from "/components/chat/input/input-store.js";
7 import * as API from "/js/api.js";
8 +import { getCurrentUserISOString } from "/js/time-utils.js";
9
10 const model = {
11 // State
@@ -44,7 +45,7 @@ const model = {
45 hostname: window.location.hostname,
46 port: window.location.port,
47 browser: navigator.userAgent,
47 - timestamp: new Date().toISOString(),
48 + timestamp: getCurrentUserISOString(),
49 };
50 },
51
webui/index.html
+2
@@ -70,6 +70,8 @@
70 id: "{{runtime_id}}",
71 isDevelopment: "{{runtime_is_development}}" === "true",
72 loggedIn: "{{logged_in}}" === "true",
73 + timezone: "{{user_timezone_setting}}",
74 + timeFormat: "{{user_time_format_setting}}",
75 };
76 </script>
77 <!-- Plugin head injections (scripts, stylesheets) -->
webui/index.js
+18 -17
@@ -14,6 +14,7 @@ import { store as chatTopStore } from "/components/chat/top-section/chat-top-sto
14 import { store as _tooltipsStore } from "/components/tooltips/tooltip-store.js";
15 import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
16 import { store as syncStore } from "/components/sync/sync-store.js"
17 +import { getUserHour12, getUserTimezone } from "/js/time-utils.js";
18
19 globalThis.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
20
@@ -198,20 +199,21 @@ async function updateUserTime() {
199 }
200
201 const now = new Date();
201 - const hours = now.getHours();
202 - const minutes = now.getMinutes();
203 - const seconds = now.getSeconds();
204 - const ampm = hours >= 12 ? "pm" : "am";
205 - const formattedHours = hours % 12 || 12;
206 -
207 - // Format the time
208 - const timeString = `${formattedHours}:${minutes
209 - .toString()
210 - .padStart(2, "0")}:${seconds.toString().padStart(2, "0")} ${ampm}`;
211 -
212 - // Format the date
213 - const options = { year: "numeric", month: "short", day: "numeric" };
214 - const dateString = now.toLocaleDateString(undefined, options);
202 + const timezone = getUserTimezone();
203 + const hour12 = getUserHour12();
204 + const timeString = new Intl.DateTimeFormat(undefined, {
205 + hour: "numeric",
206 + minute: "2-digit",
207 + second: "2-digit",
208 + hour12,
209 + timeZone: timezone,
210 + }).format(now).toLowerCase();
211 + const dateString = new Intl.DateTimeFormat(undefined, {
212 + year: "numeric",
213 + month: "short",
214 + day: "numeric",
215 + timeZone: timezone,
216 + }).format(now);
217
218 // Update the HTML
219 userTimeElement.innerHTML = `${timeString}<br><span id="user-date">${dateString}</span>`;
@@ -288,7 +290,7 @@ let lastSpokenNo = 0;
290
291 export function buildStateRequestPayload(options = {}) {
292 const { forceFull = false } = options || {};
291 - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
293 + const timezone = getUserTimezone();
294 return {
295 context: context || null,
296 log_from: forceFull ? 0 : lastLogVersion,
@@ -415,8 +417,7 @@ export async function applySnapshot(snapshot, options = {}) {
417
418 export async function poll() {
419 try {
418 - // Get timezone from navigator
419 - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
420 + const timezone = getUserTimezone();
421
422 const log_from = lastLogVersion;
423 const response = await sendJsonData("/poll", {
webui/js/messages.js
+14 -8
@@ -10,7 +10,12 @@ import {
10 } from "/components/messages/action-buttons/simple-action-buttons.js";
11 import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
12 import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
13 -import { formatDuration } from "./time-utils.js";
13 +import {
14 + formatDateTime,
15 + formatDuration,
16 + getUserHour12,
17 + getUserTimezone,
18 +} from "./time-utils.js";
19 import { Scroller } from "./scroller.js";
20 import { callJsExtensions } from "/js/extensions.js";
21 import { addBlankTargetsToLinks } from "/js/html-links.js";
@@ -2096,14 +2101,15 @@ function updateProcessGroupHeader(group) {
2101 const startTimestamp = group.getAttribute("data-start-timestamp");
2102 if (timeMetricEl && startTimestamp) {
2103 const date = new Date(parseFloat(startTimestamp) * 1000);
2099 - const hours = String(date.getHours()).padStart(2, "0");
2100 - const minutes = String(date.getMinutes()).padStart(2, "0");
2101 - timeMetricEl.textContent = `${hours}:${minutes}`;
2104 + const hour12 = getUserHour12();
2105 + timeMetricEl.textContent = new Intl.DateTimeFormat(undefined, {
2106 + hour: hour12 ? "numeric" : "2-digit",
2107 + minute: "2-digit",
2108 + hour12,
2109 + timeZone: getUserTimezone(),
2110 + }).format(date);
2111 if (timeMetricContainerEl) {
2103 - const fullDateTime = date.toLocaleString(undefined, {
2104 - dateStyle: "medium",
2105 - timeStyle: "short",
2106 - });
2112 + const fullDateTime = formatDateTime(date.toISOString(), "short");
2113 timeMetricContainerEl.title =
2114 timeMetricContainerEl.dataset.bsOriginalTitle = fullDateTime;
2115 }
webui/js/time-utils.js
+231 -12
@@ -1,30 +1,34 @@
1 /**
2 - * Time utilities for handling UTC to local time conversion
2 + * Time utilities for handling user-local time conversion.
3 */
4
5 +const TIME_FORMAT_12H = "12h";
6 +const TIME_FORMAT_24H = "24h";
7 +
8 /**
6 - * Convert a UTC ISO string to a local time string
7 - * @param {string} utcIsoString - UTC time in ISO format
9 + * Convert an ISO string to a local time string
10 + * @param {string} utcIsoString - ISO time string
11 * @param {Object} options - Formatting options for Intl.DateTimeFormat
12 * @returns {string} Formatted local time string
13 */
14 export function toLocalTime(utcIsoString, options = {}) {
15 if (!utcIsoString) return '';
16
14 - const date = new Date(utcIsoString);
17 + const date = utcIsoString instanceof Date ? utcIsoString : new Date(utcIsoString);
18 const defaultOptions = {
19 dateStyle: 'medium',
17 - timeStyle: 'medium'
20 + timeStyle: 'medium',
21 + timeZone: getUserTimezone(),
22 };
23
24 return new Intl.DateTimeFormat(
25 undefined, // Use browser's locale
22 - { ...defaultOptions, ...options }
26 + withUserTimeFormatOptions({ ...defaultOptions, ...options })
27 ).format(date);
28 }
29
30 /**
27 - * Convert a local Date object to UTC ISO string
31 + * Convert a Date object to a UTC ISO string.
32 * @param {Date} date - Date object in local time
33 * @returns {string} UTC ISO string
34 */
@@ -34,16 +38,171 @@ export function toUTCISOString(date) {
38 }
39
40 /**
37 - * Get current time as UTC ISO string
41 + * Get current time as a UTC ISO string.
42 * @returns {string} Current UTC time in ISO format
43 */
44 export function getCurrentUTCISOString() {
45 return new Date().toISOString();
46 }
47
48 +function padNumber(value, width = 2) {
49 + return String(value).padStart(width, "0");
50 +}
51 +
52 +function getTimeZoneParts(date, timeZone) {
53 + const formatter = new Intl.DateTimeFormat("en-US", {
54 + timeZone,
55 + year: "numeric",
56 + month: "2-digit",
57 + day: "2-digit",
58 + hour: "2-digit",
59 + minute: "2-digit",
60 + second: "2-digit",
61 + hourCycle: "h23",
62 + });
63 + const parts = Object.fromEntries(
64 + formatter.formatToParts(date)
65 + .filter((part) => part.type !== "literal")
66 + .map((part) => [part.type, part.value])
67 + );
68 + return {
69 + year: Number(parts.year),
70 + month: Number(parts.month),
71 + day: Number(parts.day),
72 + hour: Number(parts.hour),
73 + minute: Number(parts.minute),
74 + second: Number(parts.second),
75 + millisecond: date.getMilliseconds(),
76 + };
77 +}
78 +
79 +export function getUserDateTimeParts(date = new Date()) {
80 + return getTimeZoneParts(date, getUserTimezone());
81 +}
82 +
83 +function getTimeZoneOffsetMinutes(date, timeZone, parts = getTimeZoneParts(date, timeZone)) {
84 + const asUtc = Date.UTC(
85 + parts.year,
86 + parts.month - 1,
87 + parts.day,
88 + parts.hour,
89 + parts.minute,
90 + parts.second,
91 + parts.millisecond,
92 + );
93 + return Math.round((asUtc - date.getTime()) / 60_000);
94 +}
95 +
96 +function formatOffset(offsetMinutes) {
97 + const sign = offsetMinutes >= 0 ? "+" : "-";
98 + const absOffset = Math.abs(offsetMinutes);
99 + return `${sign}${padNumber(Math.floor(absOffset / 60))}:${padNumber(absOffset % 60)}`;
100 +}
101 +
102 +function formatPartsAsIso(parts, offsetMinutes) {
103 + return [
104 + parts.year,
105 + "-",
106 + padNumber(parts.month),
107 + "-",
108 + padNumber(parts.day),
109 + "T",
110 + padNumber(parts.hour),
111 + ":",
112 + padNumber(parts.minute),
113 + ":",
114 + padNumber(parts.second),
115 + ".",
116 + padNumber(parts.millisecond, 3),
117 + formatOffset(offsetMinutes),
118 + ].join("");
119 +}
120 +
121 +function getLocalDateParts(date) {
122 + return {
123 + year: date.getFullYear(),
124 + month: date.getMonth() + 1,
125 + day: date.getDate(),
126 + hour: date.getHours(),
127 + minute: date.getMinutes(),
128 + second: date.getSeconds(),
129 + millisecond: date.getMilliseconds(),
130 + };
131 +}
132 +
133 +function getWallClockOffsetMinutes(parts, timeZone) {
134 + const wallClockUtc = Date.UTC(
135 + parts.year,
136 + parts.month - 1,
137 + parts.day,
138 + parts.hour,
139 + parts.minute,
140 + parts.second,
141 + parts.millisecond,
142 + );
143 + let offsetMinutes = getTimeZoneOffsetMinutes(new Date(wallClockUtc), timeZone);
144 + for (let attempt = 0; attempt < 3; attempt += 1) {
145 + const instant = new Date(wallClockUtc - offsetMinutes * 60_000);
146 + const nextOffset = getTimeZoneOffsetMinutes(instant, timeZone);
147 + if (nextOffset === offsetMinutes) return offsetMinutes;
148 + offsetMinutes = nextOffset;
149 + }
150 + return offsetMinutes;
151 +}
152 +
153 +/**
154 + * Convert a Date object to an ISO string with the user's local UTC offset.
155 + * @param {Date} date - Date object in local time
156 + * @returns {string} Local ISO string, e.g. 2026-05-03T10:15:30.000+02:00
157 + */
158 +export function toUserISOString(date = new Date()) {
159 + if (!date) return "";
160 + const timeZone = getUserTimezone();
161 + const parts = getTimeZoneParts(date, timeZone);
162 + const offsetMinutes = getTimeZoneOffsetMinutes(date, timeZone, parts);
163 + return formatPartsAsIso(parts, offsetMinutes);
164 +}
165 +
166 +/**
167 + * Interpret a browser-local Date's visible wall-clock fields in the configured user timezone.
168 + * Use this for date/time picker values where the selected calendar fields matter more than
169 + * the browser's local instant.
170 + * @param {Date} date - Date object whose local fields came from user input
171 + * @returns {string} User-timezone ISO string preserving the selected wall-clock fields
172 + */
173 +export function toUserWallClockISOString(date = new Date()) {
174 + if (!date) return "";
175 + const timeZone = getUserTimezone();
176 + const parts = getLocalDateParts(date);
177 + const offsetMinutes = getWallClockOffsetMinutes(parts, timeZone);
178 + return formatPartsAsIso(parts, offsetMinutes);
179 +}
180 +
181 /**
45 - * Format a UTC ISO string for display in local time with configurable format
46 - * @param {string} utcIsoString - UTC time in ISO format
182 + * Get current time as an ISO string with the user's local UTC offset.
183 + * @returns {string}
184 + */
185 +export function getCurrentUserISOString() {
186 + return toUserISOString(new Date());
187 +}
188 +
189 +/**
190 + * Get current user-local calendar date as YYYY-MM-DD.
191 + * @returns {string}
192 + */
193 +export function getCurrentUserDateString() {
194 + const now = new Date();
195 + const parts = getTimeZoneParts(now, getUserTimezone());
196 + return [
197 + parts.year,
198 + padNumber(parts.month),
199 + padNumber(parts.day),
200 + ].join("-");
201 +}
202 +
203 +/**
204 + * Format an ISO string for display in local time with configurable format
205 + * @param {string} utcIsoString - ISO time string
206 * @param {string} format - Format type ('full', 'date', 'time', 'short')
207 * @returns {string} Formatted local time string
208 */
@@ -51,6 +210,7 @@ export function formatDateTime(utcIsoString, format = 'full') {
210 if (!utcIsoString) return '';
211
212 const date = new Date(utcIsoString);
213 + if (Number.isNaN(date.getTime())) return String(utcIsoString);
214
215 const formatOptions = {
216 full: { dateStyle: 'medium', timeStyle: 'medium' },
@@ -59,7 +219,7 @@ export function formatDateTime(utcIsoString, format = 'full') {
219 short: { dateStyle: 'short', timeStyle: 'short' }
220 };
221
62 - return toLocalTime(utcIsoString, formatOptions[format] || formatOptions.full);
222 + return toLocalTime(date, formatOptions[format] || formatOptions.full);
223 }
224
225 /**
@@ -67,7 +227,66 @@ export function formatDateTime(utcIsoString, format = 'full') {
227 * @returns {string} Timezone name (e.g., 'America/New_York')
228 */
229 export function getUserTimezone() {
70 - return Intl.DateTimeFormat().resolvedOptions().timeZone;
230 + const configured = String(globalThis.runtimeInfo?.timezone || "").trim();
231 + if (configured && configured !== "auto") return configured;
232 + return getBrowserTimezone();
233 +}
234 +
235 +export function getBrowserTimezone() {
236 + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
237 +}
238 +
239 +export function setConfiguredTimezone(timezone) {
240 + globalThis.runtimeInfo = {
241 + ...(globalThis.runtimeInfo || {}),
242 + timezone: String(timezone || "auto").trim() || "auto",
243 + };
244 +}
245 +
246 +function normalizeTimeFormat(timeFormat) {
247 + return String(timeFormat || "")
248 + .trim()
249 + .toLowerCase() === TIME_FORMAT_24H
250 + ? TIME_FORMAT_24H
251 + : TIME_FORMAT_12H;
252 +}
253 +
254 +/**
255 + * Get the preferred clock display format.
256 + * @returns {"12h" | "24h"}
257 + */
258 +export function getUserTimeFormat() {
259 + return normalizeTimeFormat(
260 + globalThis.runtimeInfo?.timeFormat || globalThis.runtimeInfo?.time_format
261 + );
262 +}
263 +
264 +/**
265 + * Return whether user-facing times should use AM/PM.
266 + * @returns {boolean}
267 + */
268 +export function getUserHour12() {
269 + return getUserTimeFormat() === TIME_FORMAT_12H;
270 +}
271 +
272 +export function setConfiguredTimeFormat(timeFormat) {
273 + globalThis.runtimeInfo = {
274 + ...(globalThis.runtimeInfo || {}),
275 + timeFormat: normalizeTimeFormat(timeFormat),
276 + };
277 +}
278 +
279 +export function withUserTimeFormatOptions(options = {}) {
280 + const formatted = { ...options };
281 + if (
282 + formatted.timeStyle ||
283 + formatted.hour ||
284 + formatted.minute ||
285 + formatted.second
286 + ) {
287 + formatted.hour12 = getUserHour12();
288 + }
289 + return formatted;
290 }
291
292 /**
webui/js/websocket.js
+2 -1
@@ -1,5 +1,6 @@
1 import { io } from "/vendor/socket.io.esm.min.js";
2 import { getCsrfToken, getRuntimeId, invalidateCsrfToken } from "/js/api.js";
3 +import { getCurrentUserISOString } from "/js/time-utils.js";
4
5 const MAX_PAYLOAD_BYTES = 50 * 1024 * 1024; // 50MB hard cap per contract
6 const DEFAULT_TIMEOUT_MS = 0;
@@ -333,7 +334,7 @@ class WebSocketClient {
334 }
335
336 buildPayload(data) {
336 - const ts = new Date().toISOString();
337 + const ts = getCurrentUserISOString();
338 if (data == null) {
339 return { ts, data: {} };
340 }