diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..a4b9b33c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,30 @@
+# Changelog
+
+All notable changes to taOS are documented in this file.
+
+Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotion.
+
+## [Unreleased]
+
+## [1.0.0-beta.2] - 2026-06-16
+
+### Added
+- Mail app with IMAP/SMTP account setup, message list, read, and send.
+- Reddit, YouTube, GitHub, and X apps available as optional Store installs.
+- Agent-callable screenshot endpoint for desktop-control workflows.
+
+### Changed
+- Browser app redesigned with the Store/Images design bar and taos.my set as the default homepage, with automatic dark/light scheme applied to proxied sites.
+- Projects app shell redesigned with a Workspace hero tab.
+- Notification bell wired to the backend feed with actionable click routing to the originating app or agent.
+- Updates panel now shows version numbers (e.g. 1.0.0-beta.2) as the primary display, with commit SHAs as a secondary detail.
+
+### Fixed
+- Controller restart time reduced from ~46 s to ~7 s by eliminating the graceful-stop hang.
+- Projects canvas crash caused by malformed element payloads written by agents.
+- Window move and resize jitter under rapid pointer events.
+
+## [1.0.0-beta.1] - 2026-06-09
+
+Initial source-available public beta release under the taOS Sustainable Use License v0.1.
diff --git a/desktop/package.json b/desktop/package.json
index cc109b2f..31ff3b09 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "tinyagentos-desktop",
"private": true,
- "version": "1.0.0-beta",
+ "version": "1.0.0-beta.2",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx
index b4805a80..2f34e9f2 100644
--- a/desktop/src/App.tsx
+++ b/desktop/src/App.tsx
@@ -283,7 +283,12 @@ export function App() {
Return to fullscreen
)}
-
+ {/* Keep the entrance zoom (scale-95 -> none animates), but do NOT
+ retain a transform once launched: a `transform` on this ancestor
+ (even scale(1)) makes a containing block for every position:fixed
+ descendant, which pins the dock to the top and renders context
+ menus in the wrong corner. Steady state must be transform-free. */}
+
diff --git a/desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx b/desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx
index 42cbc80a..3dca9f78 100644
--- a/desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx
+++ b/desktop/src/apps/SettingsApp/UpdatesPanel.test.tsx
@@ -7,7 +7,8 @@ const jResp = (b: any) => Promise.resolve({ ok: true, json: async () => b } as a
beforeEach(() => {
global.fetch = vi.fn(async (url: string) => {
if (url === "/api/preferences/auto-update") return jResp({ check_enabled: true });
- if (url === "/api/settings/update-check") return jResp({ has_updates: false, current_commit: "abc x" });
+ if (url === "/api/settings/update-check")
+ return jResp({ has_updates: false, current_version: "1.0.0-beta.2", current_commit: "abc x" });
if (url === "/api/settings/update-status") return jResp({ current_sha: "abc", pending_restart_sha: null });
if (url === "/api/settings/branches") return jResp({ branches: ["master", "dev"], current: "dev" });
if (url === "/api/settings/update-channel") return jResp({ status: "switching", branch: "master" });
@@ -15,6 +16,32 @@ beforeEach(() => {
}) as any;
});
+describe("UpdatesPanel — version display", () => {
+ it("shows current version prominently when up to date", async () => {
+ render(
);
+ await waitFor(() => expect(screen.getByText("1.0.0-beta.2")).toBeInTheDocument());
+ });
+
+ it("shows available version when an update is present", async () => {
+ (global.fetch as any) = vi.fn(async (url: string) => {
+ if (url === "/api/preferences/auto-update") return jResp({ check_enabled: true });
+ if (url === "/api/settings/update-check")
+ return jResp({
+ has_updates: true,
+ current_version: "1.0.0-beta.2",
+ new_version: "1.0.0-beta.3",
+ current_commit: "abc defg",
+ new_commit: "xyz uvwx",
+ });
+ if (url === "/api/settings/update-status") return jResp({ current_sha: "abc", pending_restart_sha: null });
+ return jResp({});
+ });
+ render(
);
+ await waitFor(() => expect(screen.getByText("1.0.0-beta.2")).toBeInTheDocument());
+ await waitFor(() => expect(screen.getByText("1.0.0-beta.3")).toBeInTheDocument());
+ });
+});
+
describe("UpdatesPanel — branch selector", () => {
it("hides the branch selector until Advanced is expanded", async () => {
render(
);
diff --git a/desktop/src/apps/SettingsApp/UpdatesPanel.tsx b/desktop/src/apps/SettingsApp/UpdatesPanel.tsx
index b432c0e0..ec87b819 100644
--- a/desktop/src/apps/SettingsApp/UpdatesPanel.tsx
+++ b/desktop/src/apps/SettingsApp/UpdatesPanel.tsx
@@ -6,6 +6,7 @@ import { RestartProgressModal } from "@/apps/SettingsApp/_shared";
interface UpdateInfo {
has_updates: boolean;
current_version: string;
+ new_version?: string | null;
current_commit: string;
new_commit?: string | null;
}
@@ -234,17 +235,31 @@ export function UpdatesPanel() {
taOS
{info?.has_updates && info.new_commit ? (
-
- installed {info.current_commit}
+
+ installed
+ {info.current_version}
+ {info.new_version ? (
+ <>
+ →
+ {info.new_version}
+ >
+ ) : null}
-
- available {info.new_commit}
+
+ {info.current_commit} → {info.new_commit}
) : (
-
- {info?.current_commit ?? "v0.1.0-dev"}
-
+
+
+ {info?.current_version ?? "unknown"}
+
+ {info?.current_commit ? (
+
+ {info.current_commit}
+
+ ) : null}
+
)}
{info?.has_updates && (
diff --git a/desktop/src/components/ContextMenu.tsx b/desktop/src/components/ContextMenu.tsx
index e540c279..98e11611 100644
--- a/desktop/src/components/ContextMenu.tsx
+++ b/desktop/src/components/ContextMenu.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
import { useIsMobile } from "@/hooks/use-is-mobile";
export interface MenuItem {
@@ -103,7 +104,11 @@ export function ContextMenu({ x, y, items, onClose }: Props) {
// Roving tabindex: track which navigable (non-separator, non-disabled) item is active
let navigableCounter = -1;
- return (
+ // Portal to so the menu is never trapped inside a transformed ancestor
+ // (e.g. the dock's -translate-x-1/2). A transform on any ancestor makes a
+ // containing block for position:fixed, which would offset the menu away from
+ // the cursor. Rendered on body, its fixed coordinates are viewport-relative.
+ return createPortal(
);
})}
-
+
,
+ document.body,
);
}
diff --git a/desktop/src/components/DockIcon.tsx b/desktop/src/components/DockIcon.tsx
index fd12ef9d..edef1257 100644
--- a/desktop/src/components/DockIcon.tsx
+++ b/desktop/src/components/DockIcon.tsx
@@ -20,6 +20,7 @@ export function DockIcon({ appId, isRunning, onClick }: Props) {
const restoreWindow = useProcessStore((s) => s.restoreWindow);
const minimizeWindow = useProcessStore((s) => s.minimizeWindow);
const maximizeWindow = useProcessStore((s) => s.maximizeWindow);
+ const recenterWindow = useProcessStore((s) => s.recenterWindow);
const closeWindow = useProcessStore((s) => s.closeWindow);
const pinned = useDockStore((s) => s.pinned);
const pin = useDockStore((s) => s.pin);
@@ -56,6 +57,11 @@ export function DockIcon({ appId, isRunning, onClick }: Props) {
icon:
,
action: () => maximizeWindow(win.id),
},
+ {
+ label: "Center Window",
+ icon:
,
+ action: () => recenterWindow(win.id),
+ },
{ label: "", separator: true },
isPinned
? { label: "Remove from Dock", icon:
, action: () => unpin(appId) }
diff --git a/desktop/src/components/Window.tsx b/desktop/src/components/Window.tsx
index 69e8f71a..60a8be39 100644
--- a/desktop/src/components/Window.tsx
+++ b/desktop/src/components/Window.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useRef, useState } from "react";
+import { memo, useCallback, useRef, useState } from "react";
import { Rnd } from "react-rnd";
import { motion, useReducedMotion } from "motion/react";
import { useProcessStore, type WindowState, type SnapPosition } from "@/stores/process-store";
@@ -13,9 +13,20 @@ interface Props {
onDragStop: () => SnapPosition;
}
-export function Window({ win, onDrag, onDragStop }: Props) {
- const { focusWindow, closeWindow, removeWindow, minimizeWindow, maximizeWindow, updatePosition, updateSize, snapWindow } =
- useProcessStore();
+function WindowImpl({ win, onDrag, onDragStop }: Props) {
+ // Select each action individually. Destructuring useProcessStore() with no
+ // selector subscribes the window to EVERY store change, so any unrelated
+ // store write would re-render it mid-drag and react-rnd would reset its
+ // controlled position. Action references are stable, so these never trigger
+ // a re-render on their own.
+ const focusWindow = useProcessStore((s) => s.focusWindow);
+ const closeWindow = useProcessStore((s) => s.closeWindow);
+ const removeWindow = useProcessStore((s) => s.removeWindow);
+ const minimizeWindow = useProcessStore((s) => s.minimizeWindow);
+ const maximizeWindow = useProcessStore((s) => s.maximizeWindow);
+ const updatePosition = useProcessStore((s) => s.updatePosition);
+ const updateBounds = useProcessStore((s) => s.updateBounds);
+ const snapWindow = useProcessStore((s) => s.snapWindow);
const app = getApp(win.appId);
const preSnapRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null);
const isMobile = useIsMobile();
@@ -95,12 +106,37 @@ export function Window({ win, onDrag, onDragStop }: Props) {
[onDragStop, snapWindow, updatePosition, win.id, win.size],
);
+ // Feed react-rnd's live position+size back every resize tick. react-rnd's
+ // position prop is controlled, and resizing from a top/left edge changes the
+ // position; without live feedback react-rnd's own internal re-render re-reads
+ // the stale stored position and the window jumps sideways mid-resize. Keeping
+ // the controlled props in lockstep with react-rnd's reported bounds keeps the
+ // resize smooth from every edge.
+ const handleResize = useCallback(
+ (
+ _e: unknown,
+ _dir: unknown,
+ ref: HTMLElement,
+ _delta: unknown,
+ position: { x: number; y: number },
+ ) => {
+ updateBounds(win.id, position.x, position.y, ref.offsetWidth, ref.offsetHeight);
+ },
+ [updateBounds, win.id],
+ );
+
const handleResizeStop = useCallback(
- (_e: unknown, _dir: unknown, ref: HTMLElement) => {
+ (
+ _e: unknown,
+ _dir: unknown,
+ ref: HTMLElement,
+ _delta: unknown,
+ position: { x: number; y: number },
+ ) => {
setDragging(false);
- updateSize(win.id, ref.offsetWidth, ref.offsetHeight);
+ updateBounds(win.id, position.x, position.y, ref.offsetWidth, ref.offsetHeight);
},
- [updateSize, win.id],
+ [updateBounds, win.id],
);
const minSize = app?.minSize ?? { w: 300, h: 200 };
@@ -142,6 +178,7 @@ export function Window({ win, onDrag, onDragStop }: Props) {
onDrag={handleDrag}
onDragStop={handleDragStop}
onResizeStart={() => setDragging(true)}
+ onResize={handleResize}
onResizeStop={handleResizeStop}
onMouseDown={() => focusWindow(win.id)}
bounds="parent"
@@ -248,3 +285,9 @@ export function Window({ win, onDrag, onDragStop }: Props) {
);
}
+
+// Memoized so unrelated desktop re-renders (snap-zone preview, the live
+// wallpaper, the agent command stream) do not re-render every window during a
+// drag. Props are stable: `win` only changes when this window's own state
+// changes, and onDrag/onDragStop are stabilized in useSnapZones.
+export const Window = memo(WindowImpl);
diff --git a/desktop/src/components/__tests__/ContextMenu.test.tsx b/desktop/src/components/__tests__/ContextMenu.test.tsx
index cdb409c8..b0c171de 100644
--- a/desktop/src/components/__tests__/ContextMenu.test.tsx
+++ b/desktop/src/components/__tests__/ContextMenu.test.tsx
@@ -30,8 +30,8 @@ describe("ContextMenu keyboard navigation", () => {
});
it("ArrowDown moves focus to next enabled item", () => {
- const { container } = renderMenu();
- const menu = container.firstChild as HTMLElement;
+ renderMenu();
+ const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "ArrowDown" });
const items = screen.getAllByRole("menuitem");
// Skip disabled "Delete", so ArrowDown from Copy → Paste
@@ -39,8 +39,8 @@ describe("ContextMenu keyboard navigation", () => {
});
it("ArrowDown wraps from last to first enabled item", () => {
- const { container } = renderMenu();
- const menu = container.firstChild as HTMLElement;
+ renderMenu();
+ const menu = screen.getByRole("menu");
// Navigate to last enabled item (Rename)
fireEvent.keyDown(menu, { key: "End" });
fireEvent.keyDown(menu, { key: "ArrowDown" });
@@ -49,8 +49,8 @@ describe("ContextMenu keyboard navigation", () => {
});
it("ArrowUp moves focus to previous enabled item", () => {
- const { container } = renderMenu();
- const menu = container.firstChild as HTMLElement;
+ renderMenu();
+ const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "ArrowDown" }); // Paste
fireEvent.keyDown(menu, { key: "ArrowUp" }); // back to Copy
const items = screen.getAllByRole("menuitem");
@@ -58,8 +58,8 @@ describe("ContextMenu keyboard navigation", () => {
});
it("Home moves focus to first enabled item", () => {
- const { container } = renderMenu();
- const menu = container.firstChild as HTMLElement;
+ renderMenu();
+ const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "ArrowDown" });
fireEvent.keyDown(menu, { key: "Home" });
const items = screen.getAllByRole("menuitem");
@@ -67,8 +67,8 @@ describe("ContextMenu keyboard navigation", () => {
});
it("End moves focus to last enabled item", () => {
- const { container } = renderMenu();
- const menu = container.firstChild as HTMLElement;
+ renderMenu();
+ const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "End" });
// Rename is last enabled (Delete is disabled)
const items = screen.getAllByRole("menuitem");
@@ -77,15 +77,15 @@ describe("ContextMenu keyboard navigation", () => {
it("Escape calls onClose", () => {
const onClose = vi.fn();
- const { container } = renderMenu(onClose);
- const menu = container.firstChild as HTMLElement;
+ renderMenu(onClose);
+ const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
it("disabled item is skipped by arrow navigation", () => {
- const { container } = renderMenu();
- const menu = container.firstChild as HTMLElement;
+ renderMenu();
+ const menu = screen.getByRole("menu");
// ArrowDown from Copy → Paste, ArrowDown again → Rename (skipping disabled Delete)
fireEvent.keyDown(menu, { key: "ArrowDown" }); // Paste
fireEvent.keyDown(menu, { key: "ArrowDown" }); // Rename (skips Delete)
@@ -106,8 +106,8 @@ describe("ContextMenu keyboard navigation", () => {
{ label: "Copy", action: vi.fn(), disabled: true },
{ label: "Paste", action: vi.fn(), disabled: true },
];
- const { container } = render(
);
- const menu = container.firstChild as HTMLElement;
+ render(
);
+ const menu = screen.getByRole("menu");
// None of these should throw or attempt to focus undefined
expect(() => fireEvent.keyDown(menu, { key: "ArrowDown" })).not.toThrow();
expect(() => fireEvent.keyDown(menu, { key: "ArrowUp" })).not.toThrow();
@@ -115,8 +115,9 @@ describe("ContextMenu keyboard navigation", () => {
expect(() => fireEvent.keyDown(menu, { key: "End" })).not.toThrow();
// Escape still calls onClose even with all disabled
const onClose = vi.fn();
- const { container: c2 } = render(
);
- fireEvent.keyDown(c2.firstChild as HTMLElement, { key: "Escape" });
+ render(
);
+ const menus = screen.getAllByRole("menu");
+ fireEvent.keyDown(menus[menus.length - 1], { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
@@ -160,8 +161,8 @@ describe("ContextMenu keyboard navigation", () => {
});
it("ArrowDown when focus is outside list moves to first enabled item", () => {
- const { container } = renderMenu();
- const menu = container.firstChild as HTMLElement;
+ renderMenu();
+ const menu = screen.getByRole("menu");
// Move focus to a focusable element outside the menu
const outside = document.createElement("button");
document.body.appendChild(outside);
@@ -174,8 +175,8 @@ describe("ContextMenu keyboard navigation", () => {
});
it("ArrowUp when focus is outside list moves to last enabled item", () => {
- const { container } = renderMenu();
- const menu = container.firstChild as HTMLElement;
+ renderMenu();
+ const menu = screen.getByRole("menu");
const outside = document.createElement("button");
document.body.appendChild(outside);
outside.focus();
diff --git a/desktop/src/hooks/use-snap-zones.ts b/desktop/src/hooks/use-snap-zones.ts
index 54a6d474..00ec4053 100644
--- a/desktop/src/hooks/use-snap-zones.ts
+++ b/desktop/src/hooks/use-snap-zones.ts
@@ -1,4 +1,4 @@
-import { useCallback, useState } from "react";
+import { useCallback, useRef, useState } from "react";
import type { SnapPosition } from "@/stores/process-store";
const EDGE_THRESHOLD = 16;
@@ -55,16 +55,28 @@ export function getSnapBounds(snap: SnapPosition, vp: Viewport): { x: number; y:
export function useSnapZones(viewport: Viewport) {
const [preview, setPreview] = useState
(null);
+ // Refs so onDrag/onDragStop keep a STABLE identity across renders. react-rnd's
+ // position prop is controlled, so a Window re-render mid-drag re-applies the
+ // stored position and yanks the window back ("jumping"). Stable callbacks let
+ // be memoized and skip those re-renders while a drag is in flight.
+ const previewRef = useRef(null);
+ const viewportRef = useRef(viewport);
+ viewportRef.current = viewport;
const onDrag = useCallback((x: number, y: number) => {
- setPreview(detectSnapZone(x, y, viewport));
- }, [viewport]);
+ const zone = detectSnapZone(x, y, viewportRef.current);
+ previewRef.current = zone;
+ // Only flip state when the zone actually changes, so crossing the same zone
+ // repeatedly does not re-render the desktop on every pointer move.
+ setPreview((prev) => (prev === zone ? prev : zone));
+ }, []);
- const onDragStop = useCallback(() => {
- const result = preview;
+ const onDragStop = useCallback((): SnapPosition => {
+ const result = previewRef.current;
+ previewRef.current = null;
setPreview(null);
return result;
- }, [preview]);
+ }, []);
return { preview, previewBounds: getSnapBounds(preview, viewport), onDrag, onDragStop };
}
diff --git a/desktop/src/stores/process-store.ts b/desktop/src/stores/process-store.ts
index af979a6e..5cf0aaca 100644
--- a/desktop/src/stores/process-store.ts
+++ b/desktop/src/stores/process-store.ts
@@ -35,12 +35,50 @@ interface ProcessStore {
minimizeWindow: (id: string) => void;
restoreWindow: (id: string) => void;
maximizeWindow: (id: string) => void;
+ recenterWindow: (id: string) => void;
updatePosition: (id: string, x: number, y: number) => void;
updateSize: (id: string, w: number, h: number) => void;
+ updateBounds: (id: string, x: number, y: number, w: number, h: number) => void;
snapWindow: (id: string, snap: SnapPosition) => void;
runningAppIds: () => string[];
}
+// Compute on-screen-safe bounds for a window. Used when restoring or
+// un-maximizing so a window that drifted off-screen (or is larger than the
+// current desktop) comes back at a usable size, recentered if it is no longer
+// reachable. The desktop area sits below the 32px top bar and above the ~84px
+// dock reservation, matching Window.tsx. Idempotent for windows already
+// comfortably on-screen. Pass a far-off position to force a recenter.
+function safeBounds(
+ position: { x: number; y: number },
+ size: { w: number; h: number },
+): { position: { x: number; y: number }; size: { w: number; h: number } } {
+ const topBarH = 32;
+ const dockH = 84;
+ const margin = 16;
+ const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
+ const vh = typeof window !== "undefined" ? window.innerHeight : 800;
+ const deskW = vw;
+ const deskH = vh - topBarH - dockH;
+
+ // Never larger than the desktop (minus margins); never below a usable minimum.
+ const w = Math.max(300, Math.min(size.w, deskW - margin * 2));
+ const h = Math.max(200, Math.min(size.h, deskH - margin * 2));
+
+ let { x, y } = position;
+ // "Reachable" means enough of the title bar is on the desktop to grab it.
+ const GRAB = 80;
+ const reachable = x <= deskW - GRAB && x + w >= GRAB && y >= 0 && y <= deskH - topBarH;
+ if (!reachable) {
+ x = Math.round((deskW - w) / 2);
+ y = Math.round((deskH - h) / 2);
+ }
+ // Final clamp keeps even a reachable-but-oversized window fully in view.
+ x = Math.max(margin, Math.min(x, Math.max(margin, deskW - w - margin)));
+ y = Math.max(0, Math.min(y, Math.max(0, deskH - h - margin)));
+ return { position: { x, y }, size: { w, h } };
+}
+
let idCounter = 0;
export const useProcessStore = create((set, get) => ({
@@ -123,20 +161,45 @@ export const useProcessStore = create((set, get) => ({
restoreWindow(id) {
const z = get().nextZIndex;
set((s) => ({
- windows: s.windows.map((w) =>
- w.id === id
- ? { ...w, minimized: false, focused: true, zIndex: z }
- : { ...w, focused: false }
- ),
+ windows: s.windows.map((w) => {
+ if (w.id !== id) return { ...w, focused: false };
+ // Showing a window again: if it drifted off-screen while hidden, pull
+ // it back into view. A maximized window keeps its stored bounds for
+ // when it is later un-maximized.
+ const safe = w.maximized ? {} : safeBounds(w.position, w.size);
+ return { ...w, ...safe, minimized: false, focused: true, zIndex: z };
+ }),
nextZIndex: z + 1,
}));
},
maximizeWindow(id) {
set((s) => ({
- windows: s.windows.map((w) =>
- w.id === id ? { ...w, maximized: !w.maximized } : w
- ),
+ windows: s.windows.map((w) => {
+ if (w.id !== id) return w;
+ if (!w.maximized) {
+ // Maximizing implies showing the window, even from a minimized state.
+ return { ...w, maximized: true, minimized: false };
+ }
+ // Un-maximizing: make sure the restored bounds are on-screen.
+ const safe = safeBounds(w.position, w.size);
+ return { ...w, ...safe, maximized: false };
+ }),
+ }));
+ },
+
+ recenterWindow(id) {
+ const z = get().nextZIndex;
+ set((s) => ({
+ windows: s.windows.map((w) => {
+ if (w.id !== id) return { ...w, focused: false };
+ // Force a recenter (far-off position guarantees safeBounds recenters),
+ // and ensure the window is shown and not maximized so the user can see
+ // and move it. The recovery path for a window lost off-screen.
+ const safe = safeBounds({ x: -1e6, y: -1e6 }, w.size);
+ return { ...w, ...safe, minimized: false, maximized: false, focused: true, zIndex: z };
+ }),
+ nextZIndex: z + 1,
}));
},
@@ -156,6 +219,18 @@ export const useProcessStore = create((set, get) => ({
}));
},
+ // Position and size in ONE update. A resize from a top/left edge moves the
+ // window's x/y as well as its w/h; committing them separately renders one
+ // frame with the new size but the old position, which reads as a jump. This
+ // applies both atomically.
+ updateBounds(id, x, y, w, h) {
+ set((s) => ({
+ windows: s.windows.map((win) =>
+ win.id === id ? { ...win, position: { x, y }, size: { w, h } } : win
+ ),
+ }));
+ },
+
snapWindow(id, snap) {
set((s) => ({
windows: s.windows.map((w) =>
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 00000000..97941f04
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,52 @@
+# Release process
+
+taOS uses semver beta: `1.0.0-beta.N`, incremented on every dev->master promotion.
+
+## Steps
+
+### 1. Bump version
+
+Update the version string to the next `1.0.0-beta.N` in exactly these three files (keep them identical):
+
+- `pyproject.toml` line `version = "..."`
+- `desktop/package.json` line `"version": "..."`
+- `tinyagentos/__init__.py` line `__version__ = "..."`
+
+### 2. Update CHANGELOG.md
+
+Move the items under `## [Unreleased]` into a new dated section at the top:
+
+```
+## [1.0.0-beta.N] - YYYY-MM-DD
+```
+
+Group bullets under `Added`, `Changed`, and `Fixed`. Keep each bullet one concise line.
+Leave `## [Unreleased]` empty and ready for the next cycle.
+
+### 3. Open a PR to dev
+
+Commit the version bump and changelog update together. Open a PR targeting `dev`.
+CI runs the backend pytest suite and frontend vitest on every PR; both must be green before merging.
+
+### 4. Promote dev to master
+
+Once the PR is merged to `dev`, open a follow-up PR from `dev` to `master`.
+After that PR merges, the install-count telemetry at taos.my starts recording the new version for every fresh install.
+
+### 5. Tag and create a GitHub Release
+
+On `master`, after the merge commit:
+
+```
+git tag v1.0.0-beta.N
+git push origin v1.0.0-beta.N
+```
+
+Create a GitHub Release for that tag. Paste the matching CHANGELOG section as the release body.
+The taos.my changelog page pulls from GitHub Releases, so this is the canonical public record.
+
+## Notes
+
+- The install-count ping reports the installed version per device, so each release bump gives per-build telemetry without any extra work.
+- Never tag on `dev`; tags always land on `master` after promotion.
+- Hotfixes follow the same steps: bump, changelog, PR to dev, promote, tag.
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 32a08d01..c835bba3 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -1,5 +1,11 @@
SINGLE SOURCE OF TRUTH for cross-agent handoff.
-Last updated: 2026-06-16 ~12:40 BST, @taOS (ACTIVE).
+Last updated: 2026-06-16 ~14:25 BST, @taOS (ACTIVE).
+
+▶▶ LATEST-10 2026-06-16 ~14:25 BST, dev=d66ec15c, master=1fb8f000, Pi LIVE on fix/window-drag-jitter (7b13a0a1): the window-fix branch now ALSO carries OFF-SCREEN WINDOW RECOVERY (Jay was locked out: windows dragged/resized off-screen with no way back). safeBounds() recomputes the desktop area and recenters a window on restore / un-maximize when its title bar is no longer reachable; maximize now un-minimizes (so Maximise always shows it); new recenterWindow action + a "Center Window" item in the dock right-click menu as the direct recovery affordance. This is ON TOP of the move + resize jitter fixes (LATEST-9). Deployed to the Pi, tsc clean, window/store tests pass, for Jay to confirm move + resize + recovery before promoting. CORRECTION to LATEST-8/9: dependabot #953 (cryptography 46.0.7->48.0.1) merged to MASTER, not dev (dependabot PRs target the default branch). master=1fb8f000 has the crypto patch; DEV DOES NOT yet -> back-merge master->dev at the next promotion. New dependabot #954 (uv group, 2 updates) open vs master. OPEN DEV PRs (green-pending): #955 mail security fix-forward (gitar #952 findings: validate IMAP UID before FETCH, reject CRLF header injection in to/cc/subject, fix IPv6 _strip_port); #956 versioning+changelog (bump 1.0.0-beta.2 across the 3 version files, Settings Updates shows VERSION numbers not commit SHAs, backend stops hardcoding current_version 0.1.0 + adds new_version, CHANGELOG.md + docs/RELEASING.md). #81 Phase 1 still needs a real build pass (the subagent only planned it). NEXT: Jay confirms window move/resize/recovery -> PR fix/window-drag-jitter to dev -> merge #955 + #956 -> promote dev->master (back-merging master first for the crypto patch), tag v1.0.0-beta.2 + GitHub Release.
+
+▶▶ LATEST-9 2026-06-16 ~14:00 BST, dev=963535ce, master=98e459f9, Pi LIVE on fix/window-drag-jitter (848e8588): WINDOW MOVE + RESIZE JITTER FIX deployed to the Pi for Jay to confirm BEFORE promoting. TWO distinct bugs, both from react-rnd CONTROLLED position/size props. (1) MOVE jitter ("jumping around like crazy"): any desktop re-render mid-drag re-applied the stored position and yanked the window back; the snap-zone preview (fired every pointer move), the live particle wallpaper, and the agent command stream all re-render the desktop continuously. Fix: stabilize the snap-zone onDrag/onDragStop callbacks via refs + only flip preview when the zone changes (use-snap-zones.ts); memoize Window + subscribe to process-store ACTIONS individually instead of the whole store (Window.tsx). (2) RESIZE jump (Jay: "horizontally jumps right, vertically jumps left during resize"): the resize handler saved only the new SIZE and ignored react-rnd's position arg, so a top/left-edge resize moved x/y but then re-applied the stale stored position. Fix: feed react-rnd's live position+size back via onResize, commit both atomically with a new updateBounds store action, persist final bounds on resize stop (Window.tsx + process-store.ts). 4 files total, no dep change, tsc clean, window+store+hook tests pass. Pi was on an old tip (f31a2eb7); this deploy also brought it up to current dev (mail/browser/optional-apps/canvas-fix). NEXT: on Jay's confirm of BOTH move and resize -> PR fix/window-drag-jitter to dev -> promote dev->master, then Pi back to dev. ALSO: #81 Phase 1 subagent produced a PLAN only (docs/superpowers/plans/2026-06-16-app-project-integration-p1.md), did NOT build it yet; needs a build pass. dependabot #953 (cryptography 46.0.7->48.0.1, the flagged high-sev alert) open + gate-clean, merge on green. tldraw stays as-is until #75.
+
+▶▶ LATEST-8 2026-06-16 ~13:30 BST, master=98e459f9 (PROMOTED), dev=d1c68dcd: PROMOTED dev->master via #952 (admin-merged past a flaky Kilo sandbox check; Gitar + all real CI green, NOT --delete-branch). Master now ships Mail (#945), Browser redesign + taos.my homepage (#944/#947), optional Store apps (#946), agent screenshot (#948), Projects redesign (#941), notification wiring (#939/#940), the restart-hang fix (ec724624), and the canvas crash fix (#951). BRANCH HYGIENE: enabled GitHub auto-delete-head-branches on merge (stops the pile-up at source); pruned ~20 stale local tracking refs; origin actually has 87 branches (the ~500 local count was a 2nd remote `hognek` fork + stale refs); 34 merged-into-dev origin branches are deletable on a nod. taos.my: Umami analytics snippet added DIRECTLY to all 3 site pages (jaylfc/taos-website PR #6 merged to main; self-hosted janlabs.co.uk instance, cookieless; do NOT also set UMAMI_* env or it double-loads). tldraw STAYS AS-IS until #75 replaces it (Jay: keep the watermark, no license buy; the Konva rebuild carries the custom look). #81 APP<->PROJECTS INTEGRATION: design decided + spec at docs/superpowers/specs/2026-06-16-app-project-integration-design.md. Jay's forks: per-window project pinning (each window pins its own project, null=Workspace); Workspace is the playground (no-project outputs route to type-organized workspace folders Images/Documents/Audio/...); outputs NEST in the pinned project's files// folder. Verified: user workspace = data_dir/workspace/ (Images Studio already writes under it); projects have files/ already. Phase 1 (backend resolve_output_dir helper + per-window projectId in process-store + window-chrome project switcher + Images Studio as the reference producer) BUILDING NOW. Canva product study + 8 reference screenshots in .design/research/canva/ (informs Design Studio + #81). NEXT: land #81 Phase 1; then pick the first studio build (recommend #75 canvas->Konva as the shared spine).
▶▶ LATEST-7 2026-06-16 ~12:40 BST, dev=79af4b04, Pi still on the pre-fix build (deploy held, Jay mid-flow): CANVAS CRASH ROOT CAUSE FOUND + FIXED (#951 MERGED). Jay lifted the no-Playwright rule for canvas only ("use playwright to fix canvas"); reproduced live on the Pi: the backend validates only an element's kind (the mermaid kind got 422) NOT its payload, but tldraw custom-shape props are strict, so an agent note/link/image with a missing field / wrong type (font_size:"14") / empty payload throws "ValidationError: ... Expected number, got undefined/a string" -> the element silently vanishes (pre-#949 it crashed the whole board). FIX: elementToShape now coerces every payload field + geometry + author_kind to its schema type with sane defaults so imperfect agent writes render instead of disappearing; 13 unit tests, gitar APPROVED no issues, all CI green. #950 (matchMedia scope) also merged since LATEST-6. TLDRAW REPLACEMENT DECISION: tldraw is proprietary ($6k/yr, the "Get a license for production" watermark, a redistribution clause that fits poorly with taOS being source-available) -> replace it. Research done -> migrate the canvas to Konva/react-konva + perfect-freehand (MIT), which becomes the SHARED engine for canvas + Design Studio + Presentations (task #75, do first). NEW JAY VISION (all OPTIONAL Store apps, NOT preinstalled; offline; agent-authorable; MIT/Apache only): local tasks #76 Design Studio (Canva-like, Konva), #77 Web Studio (agent-writes-Astro/HTML + Puck/GrapesJS editor + Caddy-LXC LAN-only hosting; agent NEVER touches host firewall/root, a closed-outcome broker provisions/publishes, public exposure is consent + Pro-gated via the unbuilt relay), #78 Music Studio (Tone.js + signal UI + JSON/MIDI model; ACE-Step audio + Anticipatory-MT/Magenta symbolic, all Apache; AVOID MusicGen CC-BY-NC weights), #79 Office Suite (Write TipTap / Calc Fortune-sheet+formulajs+SheetJS / Database Glide-grid over taOS backend / Presentations slide-JSON+Konva+PptxGenJS; AVOID HyperFormula/Handsontable/NocoDB/Teable/PPTist), #80 Guided Mode (OS-wide: agent highlights the UI element + narrates + steps the user through the full flow to TEACH rather than just do; builds on the desktop-control API + screenshot round-trip). Full license-vetted build-off research lives in the private roadmap doc (kept out of public issues per no-public-competitor-refs). NEXT (needs Jay greenlight): pick the first build from #75-80 (I recommend #75 canvas->Konva as the shared spine); decide the tldraw watermark interim (keep vs buy a license until the migration lands). STILL HELD: #73 single-port browser proxy over Tailscale.
diff --git a/pyproject.toml b/pyproject.toml
index 4e29f60b..7762a291 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tinyagentos"
-version = "1.0.0-beta"
+version = "1.0.0-beta.2"
description = "Self-hosted AI agent memory system for low-power hardware"
license = { file = "LICENSE" }
requires-python = ">=3.11"
diff --git a/tests/test_mail_security.py b/tests/test_mail_security.py
new file mode 100644
index 00000000..7f3ba1e4
--- /dev/null
+++ b/tests/test_mail_security.py
@@ -0,0 +1,62 @@
+"""Security validation for untrusted mail inputs and the CSP host helper."""
+
+import pytest
+
+from tinyagentos import mail_client
+from tinyagentos.middleware.security_headers import _strip_port
+
+
+class TestValidateUid:
+ def test_accepts_plain_numeric_uid(self):
+ assert mail_client._validate_uid("12345") == "12345"
+
+ @pytest.mark.parametrize(
+ "bad",
+ [
+ "",
+ "1 (RFC822)",
+ "1\r\nA OK",
+ "1:5",
+ "abc",
+ "1,2,3",
+ "*",
+ ],
+ )
+ def test_rejects_non_numeric_or_injection(self, bad):
+ with pytest.raises(mail_client.MailValidationError):
+ mail_client._validate_uid(bad)
+
+
+class TestValidateHeader:
+ def test_accepts_clean_value(self):
+ assert mail_client._validate_header("Hello there", "subject") == "Hello there"
+ assert mail_client._validate_header("a@b.test, c@d.test", "to") == "a@b.test, c@d.test"
+
+ @pytest.mark.parametrize(
+ "bad",
+ [
+ "subject\r\nBcc: victim@evil.test",
+ "name\nX-Injected: 1",
+ "value\rwith-cr",
+ "has\x00null",
+ ],
+ )
+ def test_rejects_crlf_injection(self, bad):
+ with pytest.raises(mail_client.MailValidationError):
+ mail_client._validate_header(bad, "to")
+
+
+class TestStripPort:
+ @pytest.mark.parametrize(
+ "host,expected",
+ [
+ ("example.com", "example.com"),
+ ("example.com:6969", "example.com"),
+ ("192.168.1.5:443", "192.168.1.5"),
+ ("[::1]", "[::1]"),
+ ("[::1]:6969", "[::1]"),
+ ("[2001:db8::1]:8080", "[2001:db8::1]"),
+ ],
+ )
+ def test_strips_port_without_corrupting_ipv6(self, host, expected):
+ assert _strip_port(host) == expected
diff --git a/tests/test_routes_settings.py b/tests/test_routes_settings.py
index 27ff0a2c..0bdd9c5b 100644
--- a/tests/test_routes_settings.py
+++ b/tests/test_routes_settings.py
@@ -265,6 +265,45 @@ async def test_timeout_kills_subprocess_no_orphan(self):
)
+class TestUpdateCheckVersion:
+ """update-check endpoint must return the installed version from tinyagentos.__version__."""
+
+ @pytest.mark.asyncio
+ async def test_current_version_matches_package(self, client):
+ """current_version in the response must equal tinyagentos.__version__."""
+ import asyncio
+ from unittest.mock import AsyncMock, MagicMock, patch
+
+ import tinyagentos
+
+ fake_proc = MagicMock()
+ fake_proc.returncode = 0
+ fake_proc.communicate = AsyncMock(return_value=(b"", b""))
+
+ async def fake_rev_parse(*_args, **_kwargs):
+ p = MagicMock()
+ p.communicate = AsyncMock(return_value=(b"abc123\n", b""))
+ return p
+
+ with (
+ patch(
+ "tinyagentos.routes.settings.asyncio.create_subprocess_exec",
+ side_effect=fake_rev_parse,
+ ),
+ patch(
+ "tinyagentos.auto_update.remote_is_strictly_ahead",
+ new=AsyncMock(return_value=False),
+ ),
+ ):
+ resp = await client.get("/api/settings/update-check")
+
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["current_version"] == tinyagentos.__version__, (
+ f"Expected {tinyagentos.__version__!r} but got {data['current_version']!r}"
+ )
+
+
class TestRebuildResultStructured:
"""Issue #327: rebuild_desktop_bundle_if_stale returns a structured
RebuildResult so callers don't have to string-match the message field."""
diff --git a/tinyagentos/__init__.py b/tinyagentos/__init__.py
index b63a7ea8..80256cd3 100644
--- a/tinyagentos/__init__.py
+++ b/tinyagentos/__init__.py
@@ -1 +1 @@
-__version__ = "1.0.0-beta"
+__version__ = "1.0.0-beta.2"
diff --git a/tinyagentos/mail_client.py b/tinyagentos/mail_client.py
index 1a7212a2..d6f5bda8 100644
--- a/tinyagentos/mail_client.py
+++ b/tinyagentos/mail_client.py
@@ -14,6 +14,7 @@
import asyncio
import email
import imaplib
+import re
import smtplib
from dataclasses import dataclass, field
from email.header import decode_header, make_header
@@ -33,7 +34,11 @@
MAX_MESSAGE_LIMIT = 200
-class MailFolderError(ValueError):
+class MailValidationError(ValueError):
+ """Raised when an untrusted value is unsafe to use in a mail command."""
+
+
+class MailFolderError(MailValidationError):
"""Raised when a folder name is unsafe to interpolate into an IMAP command."""
@@ -49,6 +54,32 @@ def _validate_folder(folder: str) -> str:
return folder
+_UID_RE = re.compile(r"^[0-9]+$")
+
+
+def _validate_uid(uid: str) -> str:
+ """Reject message ids that are not a plain IMAP UID.
+
+ ``uid`` is an untrusted path parameter passed straight to
+ ``conn.uid("FETCH", uid, ...)``. A non-numeric value could carry extra IMAP
+ command tokens, so we require a bare numeric UID. The ids we hand out from
+ ``list_messages`` are always numeric UIDs, so this rejects nothing legitimate."""
+ if not uid or not _UID_RE.fullmatch(uid):
+ raise MailValidationError(f"invalid message id: {uid!r}")
+ return uid
+
+
+def _validate_header(value: str, field: str) -> str:
+ """Reject CR/LF/NUL in a value destined for a MIME header.
+
+ ``to``/``cc``/``subject`` come from the client and are assigned directly to
+ message headers. A newline would let a caller inject extra headers (a hidden
+ Bcc, a spoofed From), so we forbid the line-break characters outright."""
+ if any(c in value for c in ("\r", "\n", "\x00")):
+ raise MailValidationError(f"invalid characters in {field}")
+ return value
+
+
@dataclass
class MailAccountConfig:
"""Connection details for a single account. The password is resolved from
@@ -282,6 +313,7 @@ def _get_message_blocking(
cfg: MailAccountConfig, folder: str, uid: str
) -> MessageDetail | None:
_validate_folder(folder)
+ _validate_uid(uid)
conn = _imap_connect(cfg)
try:
conn.select(f'"{folder}"', readonly=True)
@@ -306,10 +338,10 @@ def _build_outgoing(
) -> MIMEMultipart:
msg = MIMEMultipart()
msg["From"] = cfg.email_address or cfg.username
- msg["To"] = to
+ msg["To"] = _validate_header(to, "to")
if cc:
- msg["Cc"] = cc
- msg["Subject"] = subject
+ msg["Cc"] = _validate_header(cc, "cc")
+ msg["Subject"] = _validate_header(subject, "subject")
msg.attach(MIMEText(body, "plain"))
for filename, content, content_type in attachments or []:
maintype, _, subtype = content_type.partition("/")
diff --git a/tinyagentos/middleware/security_headers.py b/tinyagentos/middleware/security_headers.py
index 39553f15..60070fb3 100644
--- a/tinyagentos/middleware/security_headers.py
+++ b/tinyagentos/middleware/security_headers.py
@@ -33,6 +33,12 @@ def _build_csp(frame_src_extra: str = "") -> str:
def _strip_port(host: str) -> str:
+ # A bracketed IPv6 host ("[::1]" or "[::1]:6969") is full of colons, so a
+ # naive rsplit on ":" would corrupt it. Keep everything up to the closing
+ # bracket; for a normal "host:port" just drop the trailing port.
+ if host.startswith("["):
+ end = host.find("]")
+ return host[: end + 1] if end != -1 else host
return host.rsplit(":", 1)[0] if ":" in host else host
diff --git a/tinyagentos/routes/mail.py b/tinyagentos/routes/mail.py
index 4cb7d629..a882e8f9 100644
--- a/tinyagentos/routes/mail.py
+++ b/tinyagentos/routes/mail.py
@@ -211,7 +211,7 @@ async def get_message(
return JSONResponse({"error": "account credential missing"}, status_code=400)
try:
detail = await mail_client.get_message(cfg, folder, uid)
- except mail_client.MailFolderError as exc:
+ except mail_client.MailValidationError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
return JSONResponse({"error": f"imap error: {exc}"}, status_code=502)
@@ -240,6 +240,8 @@ async def send(
# TODO(phase-2): attachment upload pass-through (multipart) -- the
# client supports it; the route only sends a text body for now.
await mail_client.send_message(cfg, body.to, body.subject, body.body, cc=body.cc)
+ except mail_client.MailValidationError as exc:
+ return JSONResponse({"error": str(exc)}, status_code=400)
except Exception as exc:
return JSONResponse({"error": f"smtp error: {exc}"}, status_code=502)
return {"status": "sent"}
diff --git a/tinyagentos/routes/settings.py b/tinyagentos/routes/settings.py
index 6fa6c74d..4c9250bb 100644
--- a/tinyagentos/routes/settings.py
+++ b/tinyagentos/routes/settings.py
@@ -500,6 +500,8 @@ async def set_container_runtime(request: Request):
async def check_for_updates(request: Request):
"""Check if a newer version of TinyAgentOS is available on GitHub."""
import asyncio
+ import re
+ from tinyagentos import __version__
from tinyagentos.auto_update import remote_is_strictly_ahead
project_dir = str(Path(__file__).parent.parent.parent)
@@ -548,9 +550,26 @@ async def _log1(ref: str) -> str:
current = await _log1("HEAD")
new_commit = await _log1(f"origin/{branch}") if has_updates else None
+ # Read the version string from the remote branch HEAD so the UI can show
+ # the target version number without requiring a full install first.
+ new_version: str | None = None
+ if has_updates:
+ try:
+ rc, raw = await _run_capture(
+ ["git", "show", f"origin/{branch}:tinyagentos/__init__.py"],
+ cwd=project_dir,
+ )
+ if rc == 0 and raw:
+ m = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', raw)
+ if m:
+ new_version = m.group(1)
+ except Exception:
+ pass
+
return {
"has_updates": has_updates,
- "current_version": "0.1.0",
+ "current_version": __version__,
+ "new_version": new_version,
"current_commit": current,
"new_commit": new_commit,
}
diff --git a/uv.lock b/uv.lock
index 5f5a3cae..90c7faac 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3481,7 +3481,7 @@ wheels = [
[[package]]
name = "tinyagentos"
-version = "1.0.0b0"
+version = "1.0.0b2"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },