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
—
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
}