Skip to content

Commit 4d8e6d3

Browse files
authored
Merge pull request #1624 from jaylfc/dev
release: promote dev to master (v1.0.0-beta.24)
2 parents ac15f39 + af2643f commit 4d8e6d3

31 files changed

Lines changed: 1194 additions & 56 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ Versions follow semver beta: `1.0.0-beta.N`, bumped on each dev->master promotio
77

88
## [Unreleased]
99

10+
## [1.0.0-beta.24] - 2026-07-04
11+
12+
### Fixed
13+
- taOS Agent: the agent chat no longer wrongly reports "runtime unavailable" when opencode is installed system-wide. taOS now finds opencode on the PATH and at its default install location, and when the runtime genuinely can't start it shows the real error instead of a misleading generic message. The taOS Agent dialog also no longer opens as a blank window when launched from the Launchpad or Dock. Reported by @mandresve (#1615, #1616).
14+
- Settings: theme, wallpaper, dock position and dock icon size now persist across logout/login. They were applying in-session but a restore that ran before login completed left them reverting to defaults on the next session. Reported by @mandresve (#1601, #1603).
15+
- Models & Providers: when a downloaded model can't be used because the backend that serves it isn't running, taOS now says exactly that and how to fix it (install/start the backend) instead of a generic "not found anywhere", and provider connection tests report the real failure reason instead of "unknown error". Reported by @mandresve (#1599, #1600, #1614).
16+
1017
## [1.0.0-beta.23] - 2026-07-04
1118

1219
### Security

desktop/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "tinyagentos-desktop",
33
"private": true,
4-
"version": "1.0.0-beta.23",
4+
"version": "1.0.0-beta.24",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

desktop/src/App.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useSessionPersistence } from "@/hooks/use-session-persistence";
1111
import { useDeviceMode } from "@/hooks/use-device-mode";
1212
import { useIsPwa } from "@/hooks/use-is-pwa";
1313
import { useThemeStore, restoreActiveTheme, installWebkitRepaintGuards } from "@/stores/theme-store";
14+
import { useOnAuthReady } from "@/hooks/use-on-auth-ready";
1415
import { useProcessStore } from "@/stores/process-store";
1516
import { useDockStore } from "@/stores/dock-store";
1617
import { getApp } from "@/registry/app-registry";
@@ -204,15 +205,27 @@ export function App() {
204205
// primary push channel; useServerNotifications falls back to polling.
205206
useEventStream();
206207

207-
// Re-apply the persisted active theme on app boot so a reload keeps the
208-
// user's chosen theme app-wide (not only when Settings is opened).
208+
// WebKit blanks backdrop-filter surfaces when the tab is hidden then shown
209+
// again (switching back into taOS); re-composite them on return. Not
210+
// auth-gated: it only installs a visibility listener, no per-user fetch.
209211
useEffect(() => {
210-
void restoreActiveTheme();
211-
// WebKit blanks backdrop-filter surfaces when the tab is hidden then shown
212-
// again (switching back into taOS); re-composite them on return.
213212
installWebkitRepaintGuards();
214213
}, []);
215214

215+
// Re-apply the persisted active theme once authenticated, so a reload keeps
216+
// the user's chosen theme app-wide (not only when Settings is opened).
217+
//
218+
// SystemShortcuts mounts as a sibling of LoginGate, before LoginGate's own
219+
// /auth/status check resolves, so firing this on bare mount races the auth
220+
// check: right after a logout there is no session cookie yet, the fetch
221+
// inside restoreActiveTheme 401s, and — since this used to run once on
222+
// mount — it was never retried once the user logged back in (#1601).
223+
// useOnAuthReady defers it until auth is confirmed and re-runs it on a
224+
// later re-login.
225+
useOnAuthReady(() => {
226+
void restoreActiveTheme();
227+
});
228+
216229
// Apply reduce-effects / performance mode (#58) as a root attribute so the CSS
217230
// in tokens.css can strip blur, big shadows and continuous animations on
218231
// low-end devices. Reactive so the Settings toggle takes effect immediately.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
2+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3+
import { TaosAssistantWindow } from "./TaosAssistantWindow";
4+
import { useTaosAgentStore } from "@/stores/taos-agent-store";
5+
import { useProcessStore } from "@/stores/process-store";
6+
7+
// Mock child that opens a modal — keeps the test surface narrow, mirrors
8+
// TaosAssistantPanel.test.tsx's approach.
9+
vi.mock("@/components/TaosAssistantSettings", () => ({
10+
TaosAssistantSettings: ({ open }: { open: boolean }) =>
11+
open ? <div data-testid="settings-modal" /> : null,
12+
}));
13+
14+
function resetStores() {
15+
useTaosAgentStore.setState({
16+
isOpen: false,
17+
messages: [],
18+
model: "qwen3",
19+
streaming: false,
20+
settingsOpen: false,
21+
});
22+
useProcessStore.setState({ windows: [] });
23+
}
24+
25+
describe("TaosAssistantWindow", () => {
26+
beforeEach(() => {
27+
resetStores();
28+
});
29+
30+
afterEach(() => {
31+
vi.unstubAllGlobals();
32+
vi.clearAllMocks();
33+
});
34+
35+
it("renders the chat shell when launched directly (no popOut prop) — regression for #1615", () => {
36+
// Direct Launchpad/Dock launch calls openWindow("taos-agent", size) with
37+
// no props, so windows never carries a matching entry with
38+
// props.popOut. The window must still show its UI, not go blank.
39+
vi.stubGlobal(
40+
"fetch",
41+
vi.fn().mockResolvedValue({ ok: true, json: async () => ({ model: "qwen3" }) }),
42+
);
43+
44+
render(<TaosAssistantWindow windowId="win-1" />);
45+
46+
expect(screen.getByText("taOS agent")).toBeInTheDocument();
47+
expect(screen.getByRole("textbox", { name: /message taOS agent/i })).toBeInTheDocument();
48+
expect(screen.getByRole("button", { name: /send message/i })).toBeInTheDocument();
49+
});
50+
51+
it("renders the chat shell when opened via the pop-out button (props.popOut)", () => {
52+
useProcessStore.setState({
53+
windows: [
54+
{
55+
id: "win-2",
56+
appId: "taos-agent",
57+
position: { x: 0, y: 0 },
58+
size: { w: 420, h: 640 },
59+
zIndex: 1,
60+
minimized: false,
61+
maximized: false,
62+
snapped: null,
63+
focused: true,
64+
props: { popOut: true },
65+
launchNonce: 0,
66+
},
67+
],
68+
});
69+
vi.stubGlobal(
70+
"fetch",
71+
vi.fn().mockResolvedValue({ ok: true, json: async () => ({ model: "qwen3" }) }),
72+
);
73+
74+
render(<TaosAssistantWindow windowId="win-2" />);
75+
76+
expect(screen.getByText("taOS agent")).toBeInTheDocument();
77+
expect(screen.getByRole("textbox", { name: /message taOS agent/i })).toBeInTheDocument();
78+
});
79+
80+
it("surfaces the runtime error inline instead of leaving the dialog blank", async () => {
81+
vi.stubGlobal(
82+
"fetch",
83+
vi.fn().mockImplementation((url: string) => {
84+
if (typeof url === "string" && url.includes("/api/taos-agent/settings")) {
85+
return Promise.resolve({ ok: true, json: async () => ({ model: "qwen3" }) });
86+
}
87+
// /api/taos-agent/chat — simulate the runtime-unavailable 503.
88+
return Promise.resolve({
89+
ok: false,
90+
body: null,
91+
text: async () =>
92+
JSON.stringify({ error: "opencode is not installed. Install it with: curl -fsSL https://opencode.ai/install | bash" }),
93+
});
94+
}),
95+
);
96+
97+
render(<TaosAssistantWindow windowId="win-3" />);
98+
99+
const textarea = screen.getByRole("textbox", { name: /message taOS agent/i });
100+
fireEvent.change(textarea, { target: { value: "hello" } });
101+
fireEvent.click(screen.getByRole("button", { name: /send message/i }));
102+
103+
// The shell stays up (input still present) and the error is shown inline.
104+
await waitFor(() => {
105+
expect(screen.getByText(/opencode is not installed/i)).toBeInTheDocument();
106+
});
107+
expect(screen.getByRole("textbox", { name: /message taOS agent/i })).toBeInTheDocument();
108+
});
109+
});

desktop/src/apps/TaosAssistantWindow.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@ export function TaosAssistantWindow({ windowId }: { windowId: string }) {
66
const closeWindow = useProcessStore((s) => s.closeWindow);
77
const removeWindow = useProcessStore((s) => s.removeWindow);
88
const snap = useProcessStore((s) => s.snapWindow);
9-
const isPopOut = useProcessStore((s) => s.windows.find((w) => w.id === windowId)?.props?.popOut);
109

1110
const handleClose = useCallback(() => {
1211
closeWindow(windowId);
1312
setTimeout(() => removeWindow(windowId), 250);
1413
}, [closeWindow, removeWindow, windowId]);
1514

16-
if (!isPopOut) return null;
17-
15+
// This window renders the chat unconditionally: it is reached both via the
16+
// docked panel's "Pop out" button (which passes props.popOut) and directly
17+
// from the Launchpad/dock ("taos-agent" is a regular app-registry entry).
18+
// Gating render on props.popOut left the direct-launch path with nothing
19+
// to show -- an empty window with no error, no content (#1615).
1820
return (
1921
<div className="h-full w-full flex flex-col bg-shell-bg-deep">
2022
<div className="flex items-center gap-2 px-4 py-3 border-b border-white/5 shrink-0 select-none">

desktop/src/components/LoginGate.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Lock } from "lucide-react";
33
import { OnboardingScreen } from "./OnboardingScreen";
44
import { OffNetworkScreen } from "./OffNetworkScreen";
55
import { SESSION_EXPIRED_EVENT } from "@/lib/auth-guard";
6+
import { useAuthReadyStore } from "@/stores/auth-ready-store";
67

78
interface Props {
89
children: React.ReactNode;
@@ -61,6 +62,14 @@ export function LoginGate({ children }: Props) {
6162
refreshStatus();
6263
}, [refreshStatus]);
6364

65+
// Publish auth-readiness for session-scoped restore effects that live
66+
// outside LoginGate's children (e.g. useSessionPersistence, active-theme
67+
// restore) — they must not fetch per-user data until this flips true, and
68+
// must re-fetch the next time it does (see auth-ready-store.ts).
69+
useEffect(() => {
70+
useAuthReadyStore.getState().setReady(status.phase === "ready");
71+
}, [status.phase]);
72+
6473
// Listen for session-expired events from the global auth guard. Any
6574
// /api/* call returning 401 fires this; we re-run /auth/status which
6675
// will flip phase to "login" and unmount the app shell back to the
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { renderHook, act } from "@testing-library/react";
3+
import { useOnAuthReady } from "../use-on-auth-ready";
4+
import { useAuthReadyStore } from "@/stores/auth-ready-store";
5+
6+
beforeEach(() => {
7+
useAuthReadyStore.setState({ ready: false });
8+
});
9+
10+
describe("useOnAuthReady (#1601, #1603)", () => {
11+
it("does not run the callback while logged out", () => {
12+
const callback = vi.fn();
13+
renderHook(() => useOnAuthReady(callback));
14+
expect(callback).not.toHaveBeenCalled();
15+
});
16+
17+
it("runs the callback once auth becomes ready", () => {
18+
const callback = vi.fn();
19+
renderHook(() => useOnAuthReady(callback));
20+
21+
act(() => {
22+
useAuthReadyStore.setState({ ready: true });
23+
});
24+
25+
expect(callback).toHaveBeenCalledTimes(1);
26+
});
27+
28+
it("does not re-run while still authenticated (no duplicate restores per session)", () => {
29+
const callback = vi.fn();
30+
const { rerender } = renderHook(() => useOnAuthReady(callback));
31+
32+
act(() => {
33+
useAuthReadyStore.setState({ ready: true });
34+
});
35+
rerender();
36+
rerender();
37+
38+
expect(callback).toHaveBeenCalledTimes(1);
39+
});
40+
41+
it("runs the callback again after a logout/login cycle", () => {
42+
// This is the exact bug: a theme/settings restore that only ever fires
43+
// once on mount is never retried after the user logs back in following
44+
// a logout. useOnAuthReady must re-arm on the falling edge so the next
45+
// rising edge (the next login) restores the new session's data.
46+
const callback = vi.fn();
47+
renderHook(() => useOnAuthReady(callback));
48+
49+
act(() => {
50+
useAuthReadyStore.setState({ ready: true });
51+
});
52+
expect(callback).toHaveBeenCalledTimes(1);
53+
54+
act(() => {
55+
useAuthReadyStore.setState({ ready: false }); // logout
56+
});
57+
act(() => {
58+
useAuthReadyStore.setState({ ready: true }); // login again
59+
});
60+
61+
expect(callback).toHaveBeenCalledTimes(2);
62+
});
63+
});

0 commit comments

Comments
 (0)