@agent-native/dispatch 0.41.1-nightly-20261104110058 → 0.42.0
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.
- package/CHANGELOG.md +26 -55
- package/dist/actions/get-hosted-apps-settings.d.ts +5 -0
- package/dist/actions/get-hosted-apps-settings.d.ts.map +1 -0
- package/dist/actions/get-hosted-apps-settings.js +25 -0
- package/dist/actions/get-hosted-apps-settings.js.map +1 -0
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +4 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/list-connected-agents.d.ts +1 -0
- package/dist/actions/list-connected-agents.js +11 -10
- package/dist/actions/list-connected-agents.js.map +1 -1
- package/dist/actions/list_apps.js +3 -2
- package/dist/actions/list_apps.js.map +1 -1
- package/dist/actions/provider-api-register.d.ts +10 -10
- package/dist/actions/set-hosted-apps-settings.d.ts +7 -0
- package/dist/actions/set-hosted-apps-settings.d.ts.map +1 -0
- package/dist/actions/set-hosted-apps-settings.js +35 -0
- package/dist/actions/set-hosted-apps-settings.js.map +1 -0
- package/dist/components/app-card-tiers.d.ts +13 -0
- package/dist/components/app-card-tiers.d.ts.map +1 -0
- package/dist/components/app-card-tiers.js +22 -0
- package/dist/components/app-card-tiers.js.map +1 -0
- package/dist/components/app-list-row.d.ts +12 -4
- package/dist/components/app-list-row.d.ts.map +1 -1
- package/dist/components/app-list-row.js +19 -4
- package/dist/components/app-list-row.js.map +1 -1
- package/dist/components/app-open-actions.d.ts +4 -0
- package/dist/components/app-open-actions.d.ts.map +1 -1
- package/dist/components/app-open-actions.js +9 -0
- package/dist/components/app-open-actions.js.map +1 -1
- package/dist/components/app-pin-button.d.ts +9 -0
- package/dist/components/app-pin-button.d.ts.map +1 -0
- package/dist/components/app-pin-button.js +29 -0
- package/dist/components/app-pin-button.js.map +1 -0
- package/dist/components/app-tier-sections.d.ts +27 -0
- package/dist/components/app-tier-sections.d.ts.map +1 -0
- package/dist/components/app-tier-sections.js +44 -0
- package/dist/components/app-tier-sections.js.map +1 -0
- package/dist/components/chat-home-apps.d.ts.map +1 -1
- package/dist/components/chat-home-apps.js +19 -26
- package/dist/components/chat-home-apps.js.map +1 -1
- package/dist/components/connected-app-card.d.ts +3 -1
- package/dist/components/connected-app-card.d.ts.map +1 -1
- package/dist/components/connected-app-card.js +6 -3
- package/dist/components/connected-app-card.js.map +1 -1
- package/dist/components/create-app-popover.d.ts +4 -0
- package/dist/components/create-app-popover.d.ts.map +1 -1
- package/dist/components/create-app-popover.js +6 -0
- package/dist/components/create-app-popover.js.map +1 -1
- package/dist/components/dispatch-control-plane.d.ts.map +1 -1
- package/dist/components/dispatch-control-plane.js +25 -35
- package/dist/components/dispatch-control-plane.js.map +1 -1
- package/dist/components/hosted-apps-settings-group.d.ts +6 -0
- package/dist/components/hosted-apps-settings-group.d.ts.map +1 -0
- package/dist/components/hosted-apps-settings-group.js +76 -0
- package/dist/components/hosted-apps-settings-group.js.map +1 -0
- package/dist/components/layout/Layout.d.ts +6 -4
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +97 -80
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/workspace-app-card.d.ts.map +1 -1
- package/dist/components/workspace-app-card.js +9 -20
- package/dist/components/workspace-app-card.js.map +1 -1
- package/dist/components/workspace-app-layout-notice.d.ts +9 -0
- package/dist/components/workspace-app-layout-notice.d.ts.map +1 -0
- package/dist/components/workspace-app-layout-notice.js +27 -0
- package/dist/components/workspace-app-layout-notice.js.map +1 -0
- package/dist/components/workspace-app-search.d.ts.map +1 -1
- package/dist/components/workspace-app-search.js +11 -5
- package/dist/components/workspace-app-search.js.map +1 -1
- package/dist/components/workspace-template-card.d.ts.map +1 -1
- package/dist/components/workspace-template-card.js +7 -2
- package/dist/components/workspace-template-card.js.map +1 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -0
- package/dist/db/index.js.map +1 -1
- package/dist/lib/other-apps.d.ts +10 -1
- package/dist/lib/other-apps.d.ts.map +1 -1
- package/dist/lib/other-apps.js +7 -1
- package/dist/lib/other-apps.js.map +1 -1
- package/dist/lib/workspace-app-layout.d.ts +62 -6
- package/dist/lib/workspace-app-layout.d.ts.map +1 -1
- package/dist/lib/workspace-app-layout.js +196 -41
- package/dist/lib/workspace-app-layout.js.map +1 -1
- package/dist/lib/workspace-app-lists.d.ts +17 -0
- package/dist/lib/workspace-app-lists.d.ts.map +1 -0
- package/dist/lib/workspace-app-lists.js +21 -0
- package/dist/lib/workspace-app-lists.js.map +1 -0
- package/dist/lib/workspace-apps.d.ts +3 -1
- package/dist/lib/workspace-apps.d.ts.map +1 -1
- package/dist/lib/workspace-apps.js +5 -1
- package/dist/lib/workspace-apps.js.map +1 -1
- package/dist/routes/pages/apps.d.ts.map +1 -1
- package/dist/routes/pages/apps.js +23 -35
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/routes/pages/settings-page.d.ts.map +1 -1
- package/dist/routes/pages/settings-page.js +24 -3
- package/dist/routes/pages/settings-page.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +13 -18
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/app-roles.d.ts +2 -0
- package/dist/server/lib/app-roles.d.ts.map +1 -1
- package/dist/server/lib/app-roles.js +26 -7
- package/dist/server/lib/app-roles.js.map +1 -1
- package/dist/server/lib/hosted-apps-settings-schema.d.ts +6 -0
- package/dist/server/lib/hosted-apps-settings-schema.d.ts.map +1 -0
- package/dist/server/lib/hosted-apps-settings-schema.js +6 -0
- package/dist/server/lib/hosted-apps-settings-schema.js.map +1 -0
- package/dist/server/lib/hosted-apps-settings.d.ts +27 -0
- package/dist/server/lib/hosted-apps-settings.d.ts.map +1 -0
- package/dist/server/lib/hosted-apps-settings.js +72 -0
- package/dist/server/lib/hosted-apps-settings.js.map +1 -0
- package/dist/server/plugins/agent-chat.js +6 -3
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/dist/server/plugins/integrations.js +3 -3
- package/dist/server/plugins/integrations.js.map +1 -1
- package/dist/shared/hosted-apps-settings.d.ts +3 -0
- package/dist/shared/hosted-apps-settings.d.ts.map +1 -0
- package/dist/shared/hosted-apps-settings.js +3 -0
- package/dist/shared/hosted-apps-settings.js.map +1 -0
- package/package.json +4 -4
- package/src/actions/get-hosted-apps-settings.ts +29 -0
- package/src/actions/hosted-apps-settings.spec.ts +100 -0
- package/src/actions/index.ts +4 -0
- package/src/actions/list-connected-agents.spec.ts +67 -0
- package/src/actions/list-connected-agents.ts +14 -15
- package/src/actions/list_apps.spec.ts +16 -0
- package/src/actions/list_apps.ts +5 -2
- package/src/actions/set-hosted-apps-settings.ts +42 -0
- package/src/components/app-card-tiers.tsx +85 -0
- package/src/components/app-list-row.spec.tsx +54 -0
- package/src/components/app-list-row.tsx +50 -8
- package/src/components/app-open-actions.tsx +16 -0
- package/src/components/app-pin-button.spec.tsx +68 -0
- package/src/components/app-pin-button.tsx +66 -0
- package/src/components/app-tier-sections.spec.tsx +155 -0
- package/src/components/app-tier-sections.tsx +158 -0
- package/src/components/chat-home-apps.spec.tsx +135 -29
- package/src/components/chat-home-apps.tsx +115 -111
- package/src/components/connected-app-card.spec.tsx +18 -2
- package/src/components/connected-app-card.tsx +18 -1
- package/src/components/create-app-popover.tsx +22 -0
- package/src/components/dispatch-control-plane.spec.tsx +89 -20
- package/src/components/dispatch-control-plane.tsx +91 -125
- package/src/components/hosted-apps-settings-group.spec.tsx +218 -0
- package/src/components/hosted-apps-settings-group.tsx +189 -0
- package/src/components/layout/Layout.spec.tsx +356 -49
- package/src/components/layout/Layout.tsx +189 -142
- package/src/components/workspace-app-card.spec.tsx +33 -29
- package/src/components/workspace-app-card.tsx +13 -31
- package/src/components/workspace-app-layout-notice.spec.tsx +106 -0
- package/src/components/workspace-app-layout-notice.tsx +65 -0
- package/src/components/workspace-app-search.spec.tsx +53 -0
- package/src/components/workspace-app-search.tsx +39 -32
- package/src/components/workspace-template-card.spec.tsx +10 -0
- package/src/components/workspace-template-card.tsx +7 -2
- package/src/db/index.spec.ts +9 -0
- package/src/db/index.ts +1 -0
- package/src/lib/other-apps.spec.ts +25 -0
- package/src/lib/other-apps.ts +9 -0
- package/src/lib/workspace-app-layout.spec.ts +61 -3
- package/src/lib/workspace-app-layout.store.spec.tsx +261 -0
- package/src/lib/workspace-app-layout.ts +265 -48
- package/src/lib/workspace-app-lists.ts +32 -0
- package/src/lib/workspace-apps.ts +10 -2
- package/src/routes/pages/apps.spec.tsx +133 -5
- package/src/routes/pages/apps.tsx +79 -140
- package/src/routes/pages/chat.spec.tsx +66 -49
- package/src/routes/pages/settings-page.tsx +31 -3
- package/src/routes/pages/settings.spec.tsx +37 -0
- package/src/server/lib/app-creation-store.spec.ts +14 -0
- package/src/server/lib/app-creation-store.ts +16 -29
- package/src/server/lib/app-creation-store.write-observation.spec.ts +216 -0
- package/src/server/lib/app-roles.spec.ts +48 -1
- package/src/server/lib/app-roles.ts +44 -12
- package/src/server/lib/hosted-apps-settings-schema.ts +6 -0
- package/src/server/lib/hosted-apps-settings.spec.ts +154 -0
- package/src/server/lib/hosted-apps-settings.ts +108 -0
- package/src/server/plugins/agent-chat.spec.ts +16 -0
- package/src/server/plugins/agent-chat.ts +7 -2
- package/src/server/plugins/integrations.ts +3 -3
- package/src/shared/hosted-apps-settings.ts +3 -0
- package/src/styles/dispatch.css +40 -11
- package/dist/components/available-apps-section.d.ts +0 -9
- package/dist/components/available-apps-section.d.ts.map +0 -1
- package/dist/components/available-apps-section.js +0 -97
- package/dist/components/available-apps-section.js.map +0 -1
- package/dist/components/other-apps-section.d.ts +0 -35
- package/dist/components/other-apps-section.d.ts.map +0 -1
- package/dist/components/other-apps-section.js +0 -79
- package/dist/components/other-apps-section.js.map +0 -1
- package/src/components/available-apps-section.tsx +0 -239
- package/src/components/other-apps-section.spec.ts +0 -50
- package/src/components/other-apps-section.tsx +0 -254
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { CHAT_FIRST_APP_LAYOUT_STORAGE_KEY } from "@agent-native/core/client/agent-chat";
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { renderToString } from "react-dom/server";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
const appState = vi.hoisted(() => ({
|
|
9
|
+
read: vi.fn(),
|
|
10
|
+
write: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("@agent-native/core/client/application-state", () => ({
|
|
14
|
+
readClientAppState: appState.read,
|
|
15
|
+
writeClientAppState: appState.write,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
resetWorkspaceAppLayoutStoreForTests,
|
|
20
|
+
useWorkspaceAppLayout,
|
|
21
|
+
WORKSPACE_APP_LAYOUT_STATE_KEY,
|
|
22
|
+
} from "./workspace-app-layout";
|
|
23
|
+
|
|
24
|
+
function Surface({ name, appId }: { name: string; appId: string }) {
|
|
25
|
+
const { layout, persistenceError, togglePinned } = useWorkspaceAppLayout();
|
|
26
|
+
return (
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
data-surface={name}
|
|
30
|
+
data-pinned={layout.pinnedIds.join(",")}
|
|
31
|
+
data-error={persistenceError?.scope ?? ""}
|
|
32
|
+
onClick={() => togglePinned(appId)}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("useWorkspaceAppLayout", () => {
|
|
38
|
+
let container: HTMLDivElement;
|
|
39
|
+
let root: Root;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
43
|
+
resetWorkspaceAppLayoutStoreForTests();
|
|
44
|
+
localStorage.clear();
|
|
45
|
+
appState.read.mockReset().mockResolvedValue(null);
|
|
46
|
+
appState.write.mockReset().mockResolvedValue(null);
|
|
47
|
+
container = document.createElement("div");
|
|
48
|
+
document.body.appendChild(container);
|
|
49
|
+
root = createRoot(container);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
act(() => root.unmount());
|
|
54
|
+
container.remove();
|
|
55
|
+
vi.unstubAllGlobals();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const surface = (name: string) =>
|
|
59
|
+
container.querySelector<HTMLButtonElement>(`[data-surface="${name}"]`)!;
|
|
60
|
+
|
|
61
|
+
it("shares one layout so a pin on one surface never drops another's", async () => {
|
|
62
|
+
await act(async () => {
|
|
63
|
+
root.render(
|
|
64
|
+
<>
|
|
65
|
+
<Surface name="home" appId="mail" />
|
|
66
|
+
<Surface name="rail" appId="plan" />
|
|
67
|
+
</>,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await act(async () => surface("home").click());
|
|
72
|
+
await act(async () => surface("rail").click());
|
|
73
|
+
|
|
74
|
+
expect(surface("home").dataset.pinned).toBe("plan,mail");
|
|
75
|
+
expect(surface("rail").dataset.pinned).toBe("plan,mail");
|
|
76
|
+
expect(appState.write).toHaveBeenLastCalledWith(
|
|
77
|
+
WORKSPACE_APP_LAYOUT_STATE_KEY,
|
|
78
|
+
{ pinnedIds: ["plan", "mail"], orderedIds: [] },
|
|
79
|
+
);
|
|
80
|
+
expect(
|
|
81
|
+
JSON.parse(localStorage.getItem(CHAT_FIRST_APP_LAYOUT_STORAGE_KEY)!),
|
|
82
|
+
).toEqual({ pinnedIds: ["plan", "mail"], orderedIds: [] });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("replays a pin made before workspace state loads onto the saved pins", async () => {
|
|
86
|
+
let resolveRead: (value: unknown) => void = () => {};
|
|
87
|
+
appState.read.mockReturnValue(
|
|
88
|
+
new Promise((resolve) => {
|
|
89
|
+
resolveRead = resolve;
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
await act(async () => {
|
|
93
|
+
root.render(
|
|
94
|
+
<>
|
|
95
|
+
<Surface name="home" appId="mail" />
|
|
96
|
+
<Surface name="rail" appId="plan" />
|
|
97
|
+
</>,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
expect(appState.read).toHaveBeenCalledOnce();
|
|
101
|
+
|
|
102
|
+
await act(async () => surface("home").click());
|
|
103
|
+
expect(appState.write).not.toHaveBeenCalled();
|
|
104
|
+
await act(async () => {
|
|
105
|
+
resolveRead({ pinnedIds: ["calendar", "notes"], orderedIds: [] });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const replayed = {
|
|
109
|
+
pinnedIds: ["mail", "calendar", "notes"],
|
|
110
|
+
orderedIds: [],
|
|
111
|
+
};
|
|
112
|
+
expect(surface("rail").dataset.pinned).toBe("mail,calendar,notes");
|
|
113
|
+
expect(appState.write).toHaveBeenCalledOnce();
|
|
114
|
+
expect(appState.write).toHaveBeenCalledWith(
|
|
115
|
+
WORKSPACE_APP_LAYOUT_STATE_KEY,
|
|
116
|
+
replayed,
|
|
117
|
+
);
|
|
118
|
+
expect(
|
|
119
|
+
JSON.parse(localStorage.getItem(CHAT_FIRST_APP_LAYOUT_STORAGE_KEY)!),
|
|
120
|
+
).toEqual(replayed);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("keeps a pin's intent when the workspace already had it pinned", async () => {
|
|
124
|
+
// A new device shows no pins; the workspace pinned mail elsewhere.
|
|
125
|
+
let resolveRead: (value: unknown) => void = () => {};
|
|
126
|
+
appState.read.mockReturnValue(
|
|
127
|
+
new Promise((resolve) => {
|
|
128
|
+
resolveRead = resolve;
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
await act(async () => {
|
|
132
|
+
root.render(<Surface name="home" appId="mail" />);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await act(async () => surface("home").click());
|
|
136
|
+
await act(async () => {
|
|
137
|
+
resolveRead({ pinnedIds: ["mail", "notes"], orderedIds: [] });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(surface("home").dataset.pinned).toBe("mail,notes");
|
|
141
|
+
expect(appState.write).toHaveBeenCalledWith(
|
|
142
|
+
WORKSPACE_APP_LAYOUT_STATE_KEY,
|
|
143
|
+
{ pinnedIds: ["mail", "notes"], orderedIds: [] },
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("renders an empty layout on the server even with device pins", () => {
|
|
148
|
+
localStorage.setItem(
|
|
149
|
+
CHAT_FIRST_APP_LAYOUT_STORAGE_KEY,
|
|
150
|
+
JSON.stringify({ pinnedIds: ["mail"], orderedIds: [] }),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(renderToString(<Surface name="home" appId="mail" />)).toContain(
|
|
154
|
+
'data-pinned=""',
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("adopts workspace pins when nothing changed locally", async () => {
|
|
159
|
+
appState.read.mockResolvedValue({
|
|
160
|
+
pinnedIds: ["Calendar"],
|
|
161
|
+
orderedIds: [],
|
|
162
|
+
});
|
|
163
|
+
await act(async () => {
|
|
164
|
+
root.render(<Surface name="home" appId="mail" />);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(surface("home").dataset.pinned).toBe("calendar");
|
|
168
|
+
expect(appState.write).not.toHaveBeenCalled();
|
|
169
|
+
expect(
|
|
170
|
+
JSON.parse(localStorage.getItem(CHAT_FIRST_APP_LAYOUT_STORAGE_KEY)!),
|
|
171
|
+
).toEqual({ pinnedIds: ["calendar"], orderedIds: [] });
|
|
172
|
+
await act(async () => surface("home").click());
|
|
173
|
+
expect(surface("home").dataset.pinned).toBe("mail,calendar");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("never writes workspace state it could not read, and replays once it can", async () => {
|
|
177
|
+
appState.read
|
|
178
|
+
.mockRejectedValueOnce(new Error("offline"))
|
|
179
|
+
.mockRejectedValueOnce(new Error("offline"))
|
|
180
|
+
.mockResolvedValueOnce({ pinnedIds: ["calendar"], orderedIds: [] });
|
|
181
|
+
await act(async () => {
|
|
182
|
+
root.render(
|
|
183
|
+
<>
|
|
184
|
+
<Surface name="home" appId="mail" />
|
|
185
|
+
<Surface name="rail" appId="plan" />
|
|
186
|
+
</>,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
// A failed read with nothing unsaved is not reported.
|
|
190
|
+
expect(surface("home").dataset.error).toBe("");
|
|
191
|
+
|
|
192
|
+
await act(async () => surface("home").click());
|
|
193
|
+
expect(surface("home").dataset.pinned).toBe("mail");
|
|
194
|
+
expect(surface("home").dataset.error).toBe("workspace");
|
|
195
|
+
expect(appState.write).not.toHaveBeenCalled();
|
|
196
|
+
expect(
|
|
197
|
+
JSON.parse(localStorage.getItem(CHAT_FIRST_APP_LAYOUT_STORAGE_KEY)!),
|
|
198
|
+
).toEqual({ pinnedIds: ["mail"], orderedIds: [] });
|
|
199
|
+
|
|
200
|
+
// The next change retries the read and replays both onto the saved pins.
|
|
201
|
+
await act(async () => surface("rail").click());
|
|
202
|
+
const replayed = {
|
|
203
|
+
pinnedIds: ["plan", "mail", "calendar"],
|
|
204
|
+
orderedIds: [],
|
|
205
|
+
};
|
|
206
|
+
expect(appState.read).toHaveBeenCalledTimes(3);
|
|
207
|
+
expect(surface("rail").dataset.pinned).toBe("plan,mail,calendar");
|
|
208
|
+
expect(appState.write).toHaveBeenCalledOnce();
|
|
209
|
+
expect(appState.write).toHaveBeenCalledWith(
|
|
210
|
+
WORKSPACE_APP_LAYOUT_STATE_KEY,
|
|
211
|
+
replayed,
|
|
212
|
+
);
|
|
213
|
+
expect(surface("rail").dataset.error).toBe("");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("retries an unreadable workspace layout when a surface mounts again", async () => {
|
|
217
|
+
appState.read
|
|
218
|
+
.mockRejectedValueOnce(new Error("offline"))
|
|
219
|
+
.mockResolvedValueOnce({ pinnedIds: ["calendar"], orderedIds: [] });
|
|
220
|
+
await act(async () => {
|
|
221
|
+
root.render(<Surface name="home" appId="mail" />);
|
|
222
|
+
});
|
|
223
|
+
expect(surface("home").dataset.error).toBe("");
|
|
224
|
+
|
|
225
|
+
act(() => root.unmount());
|
|
226
|
+
root = createRoot(container);
|
|
227
|
+
await act(async () => {
|
|
228
|
+
root.render(<Surface name="home" appId="mail" />);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(surface("home").dataset.pinned).toBe("calendar");
|
|
232
|
+
expect(surface("home").dataset.error).toBe("");
|
|
233
|
+
expect(appState.write).not.toHaveBeenCalled();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("keeps device pins over a malformed workspace layout, then replaces it", async () => {
|
|
237
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
238
|
+
const device = { pinnedIds: ["mail"], orderedIds: [] };
|
|
239
|
+
localStorage.setItem(
|
|
240
|
+
CHAT_FIRST_APP_LAYOUT_STORAGE_KEY,
|
|
241
|
+
JSON.stringify(device),
|
|
242
|
+
);
|
|
243
|
+
appState.read.mockResolvedValue({ pinnedIds: "mail" });
|
|
244
|
+
|
|
245
|
+
await act(async () => {
|
|
246
|
+
root.render(<Surface name="home" appId="plan" />);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(surface("home").dataset.pinned).toBe("mail");
|
|
250
|
+
expect(
|
|
251
|
+
JSON.parse(localStorage.getItem(CHAT_FIRST_APP_LAYOUT_STORAGE_KEY)!),
|
|
252
|
+
).toEqual(device);
|
|
253
|
+
expect(appState.write).not.toHaveBeenCalled();
|
|
254
|
+
|
|
255
|
+
await act(async () => surface("home").click());
|
|
256
|
+
expect(appState.write).toHaveBeenLastCalledWith(
|
|
257
|
+
WORKSPACE_APP_LAYOUT_STATE_KEY,
|
|
258
|
+
{ pinnedIds: ["plan", "mail"], orderedIds: [] },
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
readClientAppState,
|
|
9
9
|
writeClientAppState,
|
|
10
10
|
} from "@agent-native/core/client/application-state";
|
|
11
|
-
import {
|
|
11
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
12
12
|
|
|
13
13
|
export const WORKSPACE_APP_LAYOUT_STATE_KEY = "chat-first-app-layout";
|
|
14
14
|
|
|
@@ -59,7 +59,7 @@ export function orderWorkspaceApps<T extends { id: string }>(
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
export function workspaceAppMatchesQuery(
|
|
62
|
-
app: { name: string; description?: string },
|
|
62
|
+
app: { name: string; description?: string | undefined },
|
|
63
63
|
query: string,
|
|
64
64
|
): boolean {
|
|
65
65
|
const normalizedQuery = query.trim().toLowerCase();
|
|
@@ -69,63 +69,280 @@ export function workspaceAppMatchesQuery(
|
|
|
69
69
|
.includes(normalizedQuery);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
/** Pins (newest first) or unpins `appId`; a no-op when it already matches. */
|
|
73
|
+
export function setWorkspaceAppPinned(
|
|
73
74
|
layout: ChatFirstAppLayoutPreference,
|
|
74
75
|
appId: string,
|
|
76
|
+
pinned: boolean,
|
|
75
77
|
): ChatFirstAppLayoutPreference {
|
|
76
78
|
const normalizedLayout = normalizeWorkspaceAppLayout(layout);
|
|
77
79
|
const normalizedAppId = appId.trim().toLowerCase();
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
const isPinned = normalizedLayout.pinnedIds.includes(normalizedAppId);
|
|
81
|
+
if (isPinned === pinned) return normalizedLayout;
|
|
82
|
+
const pinnedIds = pinned
|
|
83
|
+
? [normalizedAppId, ...normalizedLayout.pinnedIds]
|
|
84
|
+
: normalizedLayout.pinnedIds.filter((id) => id !== normalizedAppId);
|
|
81
85
|
return { ...normalizedLayout, pinnedIds };
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
export
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
export type WorkspaceAppsStatus = "loading" | "error" | "ready";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The workspace app list is known once it has arrived, and a failed refresh
|
|
92
|
+
* keeps the last one. Until then nothing can be tiered: an empty list is not
|
|
93
|
+
* "no apps", and a hosted app cannot be told from a mounted one.
|
|
94
|
+
*/
|
|
95
|
+
export function workspaceAppsStatus(query: {
|
|
96
|
+
data: unknown;
|
|
97
|
+
isError: boolean;
|
|
98
|
+
}): WorkspaceAppsStatus {
|
|
99
|
+
if (query.data !== undefined) return "ready";
|
|
100
|
+
return query.isError ? "error" : "loading";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Both app lists: ready once workspace apps arrived and the hosted list has
|
|
105
|
+
* settled (arrived or failed), so the tiers, and the header actions placed by
|
|
106
|
+
* them, do not reshuffle when hosted apps land a moment later.
|
|
107
|
+
*/
|
|
108
|
+
export function appListsStatus(
|
|
109
|
+
workspaceApps: { data: unknown; isError: boolean },
|
|
110
|
+
connectedApps: { data: unknown; isError: boolean },
|
|
111
|
+
): WorkspaceAppsStatus {
|
|
112
|
+
const status = workspaceAppsStatus(workspaceApps);
|
|
113
|
+
if (status !== "ready") return status;
|
|
114
|
+
return connectedApps.data === undefined && !connectedApps.isError
|
|
115
|
+
? "loading"
|
|
116
|
+
: "ready";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether any of `apps` is pinned, before a search narrows the list. */
|
|
120
|
+
export function hasPinnedWorkspaceApp(
|
|
121
|
+
apps: readonly { id: string }[],
|
|
122
|
+
layout: ChatFirstAppLayoutPreference,
|
|
123
|
+
): boolean {
|
|
124
|
+
const pinned = new Set(normalizeWorkspaceAppLayout(layout).pinnedIds);
|
|
125
|
+
return apps.some((app) => pinned.has(app.id.trim().toLowerCase()));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface WorkspaceAppTiers<T> {
|
|
129
|
+
pinned: T[];
|
|
130
|
+
apps: T[];
|
|
131
|
+
hosted: T[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function compareWorkspaceAppNames(
|
|
135
|
+
a: { name: string },
|
|
136
|
+
b: { name: string },
|
|
137
|
+
): number {
|
|
138
|
+
return a.name.localeCompare(b.name);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Pinned apps leave their home tier and keep pin order (newest first). The
|
|
143
|
+
* Apps and Hosted apps (built-in catalog) tiers each sort A to Z.
|
|
144
|
+
*/
|
|
145
|
+
export function tierWorkspaceApps<
|
|
146
|
+
T extends { id: string; name: string; source?: string | undefined },
|
|
147
|
+
>(
|
|
148
|
+
apps: readonly T[],
|
|
149
|
+
layout: ChatFirstAppLayoutPreference,
|
|
150
|
+
): WorkspaceAppTiers<T> {
|
|
151
|
+
const appsById = new Map(
|
|
152
|
+
apps.map((app) => [app.id.trim().toLowerCase(), app]),
|
|
87
153
|
);
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
154
|
+
const pinned = normalizeWorkspaceAppLayout(layout)
|
|
155
|
+
.pinnedIds.map((id) => appsById.get(id))
|
|
156
|
+
.filter((app): app is T => Boolean(app));
|
|
157
|
+
const pinnedSet = new Set(pinned);
|
|
158
|
+
const unpinned = apps.filter((app) => !pinnedSet.has(app));
|
|
159
|
+
return {
|
|
160
|
+
pinned,
|
|
161
|
+
apps: unpinned
|
|
162
|
+
.filter((app) => app.source !== "builtin")
|
|
163
|
+
.sort(compareWorkspaceAppNames),
|
|
164
|
+
hosted: unpinned
|
|
165
|
+
.filter((app) => app.source === "builtin")
|
|
166
|
+
.sort(compareWorkspaceAppNames),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
92
169
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
170
|
+
interface WorkspaceAppLayoutSnapshot {
|
|
171
|
+
layout: ChatFirstAppLayoutPreference;
|
|
172
|
+
/** `writeId` is new for each save, so a dismissed notice shows again. */
|
|
173
|
+
persistenceError: {
|
|
174
|
+
scope: WorkspaceAppLayoutPersistenceError;
|
|
175
|
+
writeId: number;
|
|
176
|
+
} | null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
type WorkspaceAppLayoutUpdate = (
|
|
180
|
+
layout: ChatFirstAppLayoutPreference,
|
|
181
|
+
) => ChatFirstAppLayoutPreference;
|
|
182
|
+
|
|
183
|
+
const SERVER_SNAPSHOT: WorkspaceAppLayoutSnapshot = {
|
|
184
|
+
layout: { pinnedIds: [], orderedIds: [] },
|
|
185
|
+
persistenceError: null,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// One store per page: every surface reads and writes the same pins, so a
|
|
189
|
+
// write always starts from the latest layout instead of a surface's copy.
|
|
190
|
+
let snapshot: WorkspaceAppLayoutSnapshot | null = null;
|
|
191
|
+
// Workspace state is written only after it has been read: until then a write
|
|
192
|
+
// would replace saved pins this page never saw. "failed" retries the read.
|
|
193
|
+
let hydration: "idle" | "pending" | "failed" | "done" = "idle";
|
|
194
|
+
// Changes made before workspace state was read. They replay onto the read
|
|
195
|
+
// layout, then persist.
|
|
196
|
+
let pendingUpdates: WorkspaceAppLayoutUpdate[] = [];
|
|
197
|
+
let deviceSaved = true;
|
|
198
|
+
let writeSequence = 0;
|
|
199
|
+
const listeners = new Set<() => void>();
|
|
200
|
+
|
|
201
|
+
function currentSnapshot(): WorkspaceAppLayoutSnapshot {
|
|
202
|
+
snapshot ??= {
|
|
203
|
+
layout: normalizeWorkspaceAppLayout(readChatFirstAppLayout()),
|
|
204
|
+
persistenceError: null,
|
|
205
|
+
};
|
|
206
|
+
return snapshot;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function setSnapshot(next: Partial<WorkspaceAppLayoutSnapshot>) {
|
|
210
|
+
snapshot = { ...currentSnapshot(), ...next };
|
|
211
|
+
for (const listener of listeners) listener();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function writeDeviceLayout(next: ChatFirstAppLayoutPreference) {
|
|
215
|
+
deviceSaved = writeChatFirstAppLayout(next).ok;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function settlePersistence(writeId: number, workspaceSaved: boolean) {
|
|
219
|
+
if (writeId !== writeSequence) return;
|
|
220
|
+
const scope: WorkspaceAppLayoutPersistenceError | null = workspaceSaved
|
|
221
|
+
? deviceSaved
|
|
222
|
+
? null
|
|
223
|
+
: "device"
|
|
224
|
+
: deviceSaved
|
|
225
|
+
? "workspace"
|
|
226
|
+
: "both";
|
|
227
|
+
setSnapshot({ persistenceError: scope ? { scope, writeId } : null });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function persistWorkspaceAppLayout(next: ChatFirstAppLayoutPreference) {
|
|
231
|
+
writeDeviceLayout(next);
|
|
232
|
+
const writeId = ++writeSequence;
|
|
233
|
+
void writeClientAppState(WORKSPACE_APP_LAYOUT_STATE_KEY, next).then(
|
|
234
|
+
() => settlePersistence(writeId, true),
|
|
235
|
+
() => settlePersistence(writeId, false),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function finishHydration(saved: ChatFirstAppLayoutPreference | null) {
|
|
240
|
+
hydration = "done";
|
|
241
|
+
const updates = pendingUpdates;
|
|
242
|
+
pendingUpdates = [];
|
|
243
|
+
// Absent workspace state keeps the device layout, which already holds the
|
|
244
|
+
// queued updates.
|
|
245
|
+
const next = saved
|
|
246
|
+
? updates.reduce(
|
|
247
|
+
(layout, update) => normalizeWorkspaceAppLayout(update(layout)),
|
|
248
|
+
saved,
|
|
249
|
+
)
|
|
250
|
+
: currentSnapshot().layout;
|
|
251
|
+
setSnapshot({ layout: next });
|
|
252
|
+
if (updates.length > 0) {
|
|
253
|
+
persistWorkspaceAppLayout(next);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (saved) writeDeviceLayout(next);
|
|
257
|
+
// Nothing was saved before this read, so the only error it can clear is
|
|
258
|
+
// the earlier read failure.
|
|
259
|
+
setSnapshot({ persistenceError: null });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isSavedWorkspaceAppLayout(value: unknown): boolean {
|
|
263
|
+
if (!value || typeof value !== "object") return false;
|
|
264
|
+
const candidate = value as Record<string, unknown>;
|
|
265
|
+
return (
|
|
266
|
+
Array.isArray(candidate.pinnedIds) && Array.isArray(candidate.orderedIds)
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function hydrateWorkspaceAppLayout() {
|
|
271
|
+
if (hydration === "pending" || hydration === "done") return;
|
|
272
|
+
hydration = "pending";
|
|
273
|
+
void readClientAppState<unknown>(WORKSPACE_APP_LAYOUT_STATE_KEY).then(
|
|
274
|
+
(value) => {
|
|
275
|
+
if (value === null) return finishHydration(null);
|
|
276
|
+
// A malformed value is not "no pins": treat it as absent so the device
|
|
277
|
+
// pins survive, and the next save replaces it.
|
|
278
|
+
if (!isSavedWorkspaceAppLayout(value)) {
|
|
279
|
+
console.warn(
|
|
280
|
+
`[dispatch] ignoring malformed ${WORKSPACE_APP_LAYOUT_STATE_KEY}`,
|
|
281
|
+
value,
|
|
282
|
+
);
|
|
283
|
+
return finishHydration(null);
|
|
284
|
+
}
|
|
285
|
+
finishHydration(normalizeWorkspaceAppLayout(value));
|
|
286
|
+
},
|
|
287
|
+
() => {
|
|
288
|
+
hydration = "failed";
|
|
289
|
+
// Only unsaved pins make a failed read worth reporting.
|
|
290
|
+
if (pendingUpdates.length > 0) settlePersistence(++writeSequence, false);
|
|
126
291
|
},
|
|
127
|
-
[layout, persistLayout],
|
|
128
292
|
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function subscribe(listener: () => void) {
|
|
296
|
+
listeners.add(listener);
|
|
297
|
+
return () => {
|
|
298
|
+
listeners.delete(listener);
|
|
299
|
+
};
|
|
300
|
+
}
|
|
129
301
|
|
|
130
|
-
|
|
302
|
+
export function updateWorkspaceAppLayout(update: WorkspaceAppLayoutUpdate) {
|
|
303
|
+
const next = normalizeWorkspaceAppLayout(update(currentSnapshot().layout));
|
|
304
|
+
setSnapshot({ layout: next });
|
|
305
|
+
if (hydration === "done") {
|
|
306
|
+
persistWorkspaceAppLayout(next);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
pendingUpdates.push(update);
|
|
310
|
+
writeDeviceLayout(next);
|
|
311
|
+
if (hydration === "failed") settlePersistence(++writeSequence, false);
|
|
312
|
+
hydrateWorkspaceAppLayout();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The click decides pin or unpin from what is on screen, so a change replayed
|
|
316
|
+
// onto workspace pins read later keeps that intent.
|
|
317
|
+
function togglePinned(appId: string) {
|
|
318
|
+
const pinned = !currentSnapshot().layout.pinnedIds.includes(
|
|
319
|
+
appId.trim().toLowerCase(),
|
|
320
|
+
);
|
|
321
|
+
updateWorkspaceAppLayout((layout) =>
|
|
322
|
+
setWorkspaceAppPinned(layout, appId, pinned),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Test-only: forget the page's layout so the next read starts from storage. */
|
|
327
|
+
export function resetWorkspaceAppLayoutStoreForTests() {
|
|
328
|
+
snapshot = null;
|
|
329
|
+
hydration = "idle";
|
|
330
|
+
pendingUpdates = [];
|
|
331
|
+
deviceSaved = true;
|
|
332
|
+
writeSequence = 0;
|
|
333
|
+
listeners.clear();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function useWorkspaceAppLayout({
|
|
337
|
+
hydrate = true,
|
|
338
|
+
}: { hydrate?: boolean } = {}) {
|
|
339
|
+
const { layout, persistenceError } = useSyncExternalStore(
|
|
340
|
+
subscribe,
|
|
341
|
+
currentSnapshot,
|
|
342
|
+
() => SERVER_SNAPSHOT,
|
|
343
|
+
);
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
if (hydrate) hydrateWorkspaceAppLayout();
|
|
346
|
+
}, [hydrate]);
|
|
347
|
+
return { layout, persistenceError, togglePinned };
|
|
131
348
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useActionQuery } from "@agent-native/core/client/hooks";
|
|
2
|
+
|
|
3
|
+
import { filterBuiltInApps, type ConnectedAppSummary } from "./other-apps";
|
|
4
|
+
import { appListsStatus } from "./workspace-app-layout";
|
|
5
|
+
import type { WorkspaceAppSummary } from "./workspace-apps";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The app lists behind Home and All apps. Hosted apps stay empty until both
|
|
9
|
+
* lists settle: a workspace that mounts its own copy of a hosted app is only
|
|
10
|
+
* known from the workspace list.
|
|
11
|
+
*/
|
|
12
|
+
export function useWorkspaceAppLists({
|
|
13
|
+
includeHiddenFromLaunchers = false,
|
|
14
|
+
}: { includeHiddenFromLaunchers?: boolean } = {}) {
|
|
15
|
+
const appsQuery = useActionQuery<WorkspaceAppSummary[]>(
|
|
16
|
+
"list-workspace-apps",
|
|
17
|
+
{ includeAgentCards: false, includeArchived: true },
|
|
18
|
+
);
|
|
19
|
+
const connectedAppsQuery = useActionQuery<ConnectedAppSummary[]>(
|
|
20
|
+
"list-connected-agents",
|
|
21
|
+
{},
|
|
22
|
+
);
|
|
23
|
+
const status = appListsStatus(appsQuery, connectedAppsQuery);
|
|
24
|
+
const workspaceApps = appsQuery.data ?? [];
|
|
25
|
+
const hostedApps =
|
|
26
|
+
status === "ready"
|
|
27
|
+
? filterBuiltInApps(connectedAppsQuery.data ?? [], workspaceApps, {
|
|
28
|
+
includeHiddenFromLaunchers,
|
|
29
|
+
})
|
|
30
|
+
: [];
|
|
31
|
+
return { status, workspaceApps, hostedApps, appsQuery, connectedAppsQuery };
|
|
32
|
+
}
|
|
@@ -47,6 +47,7 @@ export interface WorkspaceAppSummary {
|
|
|
47
47
|
archived?: boolean;
|
|
48
48
|
workspaceSso?: boolean;
|
|
49
49
|
source?: WorkspaceAppSource;
|
|
50
|
+
hiddenFromLaunchers?: boolean;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
export type WorkspaceAppSource = "workspace" | "builtin" | "connected";
|
|
@@ -168,9 +169,13 @@ export function isDefaultWorkspaceAppHiddenId(appId: string): boolean {
|
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
export function isWorkspaceAppVisibleInDefaultLaunchers(
|
|
171
|
-
app: Pick<WorkspaceAppSummary, "id" | "isDispatch">,
|
|
172
|
+
app: Pick<WorkspaceAppSummary, "id" | "isDispatch" | "hiddenFromLaunchers">,
|
|
172
173
|
): boolean {
|
|
173
|
-
return
|
|
174
|
+
return (
|
|
175
|
+
!app.isDispatch &&
|
|
176
|
+
!app.hiddenFromLaunchers &&
|
|
177
|
+
!isDefaultWorkspaceAppHiddenId(app.id)
|
|
178
|
+
);
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
const DEFAULT_WORKSPACE_APP_DESCRIPTIONS: Record<
|
|
@@ -506,6 +511,7 @@ export function mergeChatFirstWorkspaceApps(
|
|
|
506
511
|
description?: string | null;
|
|
507
512
|
url?: string | null;
|
|
508
513
|
source?: WorkspaceAppSource;
|
|
514
|
+
hiddenFromLaunchers?: boolean;
|
|
509
515
|
}[] = [],
|
|
510
516
|
): WorkspaceAppSummary[] {
|
|
511
517
|
const enabledBuiltins = new Set(
|
|
@@ -554,6 +560,7 @@ export function mergeChatFirstWorkspaceApps(
|
|
|
554
560
|
name: app.name.trim() || existing.name,
|
|
555
561
|
description: app.description ?? existing.description,
|
|
556
562
|
url: app.url?.trim() || existing.url,
|
|
563
|
+
...(app.hiddenFromLaunchers ? { hiddenFromLaunchers: true } : {}),
|
|
557
564
|
});
|
|
558
565
|
}
|
|
559
566
|
continue;
|
|
@@ -566,6 +573,7 @@ export function mergeChatFirstWorkspaceApps(
|
|
|
566
573
|
url: app.url?.trim() || null,
|
|
567
574
|
status: "ready",
|
|
568
575
|
source: app.source ?? "connected",
|
|
576
|
+
...(app.hiddenFromLaunchers ? { hiddenFromLaunchers: true } : {}),
|
|
569
577
|
});
|
|
570
578
|
}
|
|
571
579
|
|