palmier 0.9.4 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@ interface HostStoreContextValue {
15
15
  renamePairedHost(hostId: string, name: string): void;
16
16
  setHostLanUrl(hostId: string, lanUrl: string | undefined): void;
17
17
  setHostLastAgent(hostId: string, agent: string): void;
18
+ setHostTimezone(hostId: string, timezone: string | undefined): void;
18
19
  }
19
20
 
20
21
  const HostStoreContext = createContext<HostStoreContextValue | null>(null);
@@ -91,6 +92,16 @@ export function HostStoreProvider({ children }: { children: ReactNode }) {
91
92
  );
92
93
  }, []);
93
94
 
95
+ const setHostTimezone = useCallback((hostId: string, timezone: string | undefined) => {
96
+ setPairedHosts((prev) =>
97
+ prev.map((h) => {
98
+ if (h.hostId !== hostId) return h;
99
+ if (h.timezone === timezone) return h;
100
+ return { ...h, timezone };
101
+ })
102
+ );
103
+ }, []);
104
+
94
105
  return (
95
106
  <HostStoreContext.Provider value={{
96
107
  pairedHosts,
@@ -99,6 +110,7 @@ export function HostStoreProvider({ children }: { children: ReactNode }) {
99
110
  renamePairedHost,
100
111
  setHostLanUrl,
101
112
  setHostLastAgent,
113
+ setHostTimezone,
102
114
  }}>
103
115
  {children}
104
116
  </HostStoreContext.Provider>
@@ -1,10 +1,44 @@
1
+ import { useCallback } from "react";
2
+ import { useHostConnection } from "./contexts/HostConnectionContext";
3
+
1
4
  /**
2
5
  * Format a timestamp for display. Shows time only if today, otherwise includes the date.
6
+ * If `timeZone` is provided, renders the wall-clock time in that IANA zone.
3
7
  */
4
- export function formatTime(ms: number): string {
8
+ export function formatTime(ms: number, timeZone?: string): string {
5
9
  const d = new Date(ms);
6
10
  const now = new Date();
7
- const time = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
8
- if (d.toDateString() === now.toDateString()) return time;
9
- return `${d.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${time}`;
11
+ const time = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", timeZone });
12
+ // toDateString() would compare in the viewer's local zone, masking day
13
+ // boundaries in the host's zone. Format both dates in the target zone and
14
+ // compare the strings instead.
15
+ const dayOpts: Intl.DateTimeFormatOptions = { year: "numeric", month: "2-digit", day: "2-digit", timeZone };
16
+ const sameDay = d.toLocaleDateString(undefined, dayOpts) === now.toLocaleDateString(undefined, dayOpts);
17
+ if (sameDay) return time;
18
+ return `${d.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone })} ${time}`;
19
+ }
20
+
21
+ /** Returns a `formatTime` bound to the active host's timezone. */
22
+ export function useFormatTime(): (ms: number) => string {
23
+ const { activeHost } = useHostConnection();
24
+ const tz = activeHost.timezone;
25
+ return useCallback((ms: number) => formatTime(ms, tz), [tz]);
26
+ }
27
+
28
+ /**
29
+ * Current wall-clock in the host timezone, as ISO-shaped date + HH:MM strings.
30
+ * Used for "not in the past" validation on scheduled triggers so the floor is
31
+ * the host's now, not the viewer's now.
32
+ */
33
+ export function hostNowParts(timeZone?: string): { date: string; time: string } {
34
+ const parts = new Intl.DateTimeFormat("en-CA", {
35
+ timeZone,
36
+ year: "numeric", month: "2-digit", day: "2-digit",
37
+ hour: "2-digit", minute: "2-digit", hour12: false,
38
+ }).formatToParts(new Date());
39
+ const pick = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
40
+ return {
41
+ date: `${pick("year")}-${pick("month")}-${pick("day")}`,
42
+ time: `${pick("hour")}:${pick("minute")}`,
43
+ };
10
44
  }
@@ -46,7 +46,7 @@ interface PermissionPrompt { permissions: RequiredPermission[]; sessionName?: st
46
46
  interface InputPrompt { questions: string[]; description?: string; sessionName?: string }
47
47
 
48
48
  export default function Dashboard() {
49
- const { removePairedHost, setHostLanUrl } = useHostStore();
49
+ const { removePairedHost, setHostLanUrl, setHostTimezone } = useHostStore();
50
50
  const { connected, request, subscribeEvents, unauthorized, activeHost } = useHostConnection();
51
51
  const hostId = activeHost.hostId;
52
52
  const activeClientToken = activeHost.clientToken || null;
@@ -100,6 +100,7 @@ export default function Dashboard() {
100
100
  agents?: AgentInfo[];
101
101
  version?: string | null;
102
102
  host_platform?: string;
103
+ host_timezone?: string;
103
104
  linked_client_token?: string | null;
104
105
  pending_prompts?: PendingPrompt[];
105
106
  lan_url?: string | null;
@@ -113,6 +114,7 @@ export default function Dashboard() {
113
114
  setDaemonVersion(version);
114
115
  setUpdateRequired(!!version && isOlderThan(version, MIN_HOST_VERSION));
115
116
  setHostLanUrl(hostId, result.lan_url ?? undefined);
117
+ setHostTimezone(hostId, result.host_timezone);
116
118
 
117
119
  // Seed modal state from already-pending prompts.
118
120
  const confirms = new Map<string, ConfirmPrompt>();
@@ -146,7 +148,7 @@ export default function Dashboard() {
146
148
  setInputValues(inputVals);
147
149
  })
148
150
  .catch(() => { /* silent — update-required prompt guards the broken case */ });
149
- }, [connected, hostId, request, setHostLanUrl]);
151
+ }, [connected, hostId, request, setHostLanUrl, setHostTimezone]);
150
152
 
151
153
  // Always-on event subscription for modal lifecycle. Independent of which tab
152
154
  // is active. Task-card status updates happen inside TasksView while mounted.
@@ -70,4 +70,6 @@ export interface PairedHost {
70
70
  lanUrl?: string;
71
71
  /** Last-used agent key for this host. Seeds the agent picker on the session composer and task form. */
72
72
  lastAgent?: string;
73
+ /** IANA timezone from the host, refreshed on each `host.info`. Drives all time rendering. */
74
+ timezone?: string;
73
75
  }
@@ -148,6 +148,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
148
148
  agents: config.agents ?? [],
149
149
  version: currentVersion,
150
150
  host_platform: process.platform,
151
+ host_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
151
152
  linked_client_token: getLinkedDevice()?.clientToken ?? null,
152
153
  pending_prompts: listPending(),
153
154
  lan_url: buildLanUrl(config.httpPort ?? 7256, config.defaultInterface),
@@ -634,10 +635,23 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
634
635
  if (!params.task_id || !params.run_id) {
635
636
  return { error: "task_id and run_id are required" };
636
637
  }
638
+ const deleteTaskDir = getTaskDir(config.projectRoot, params.task_id);
639
+ let isOneOff = false;
640
+ try { isOneOff = !!parseTaskFile(deleteTaskDir).frontmatter.one_off; } catch { /* ignore */ }
641
+
637
642
  const deleted = deleteHistoryEntry(config.projectRoot, params.task_id, params.run_id);
638
643
  if (!deleted) {
639
644
  return { error: "History entry not found" };
640
645
  }
646
+
647
+ // A one-off task exists only to hold its single run — no run means no
648
+ // reason for the task to stick around.
649
+ if (isOneOff) {
650
+ try { getPlatform().removeTaskTimer(params.task_id); } catch { /* best-effort */ }
651
+ clearTaskQueue(params.task_id);
652
+ try { fs.rmSync(deleteTaskDir, { recursive: true, force: true }); } catch { /* best-effort */ }
653
+ }
654
+
641
655
  return { ok: true, task_id: params.task_id, run_id: params.run_id };
642
656
  }
643
657