@linxin666/dsh-remote-web-ui 0.1.11 → 0.1.13

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.
Files changed (68) hide show
  1. package/README.i18n.yaml +6 -0
  2. package/README.md +12 -1
  3. package/README.zh.md +171 -0
  4. package/lib/client.js +170 -99
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +224 -4
  7. package/lib/mobile.js +109 -33
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/PluginSettingsCard.js +3 -2
  11. package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
  12. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  13. package/lib/types/client/RemoteSettingsCard.js +3 -1
  14. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  15. package/lib/types/client/UpdateEntry.js +10 -2
  16. package/lib/types/client/deep-link.js +10 -5
  17. package/lib/types/client/index.d.ts +13 -1
  18. package/lib/types/client/index.d.ts.map +1 -1
  19. package/lib/types/client/index.js +2 -1
  20. package/lib/types/client/locales.d.ts +8 -0
  21. package/lib/types/client/locales.d.ts.map +1 -1
  22. package/lib/types/client/locales.js +8 -0
  23. package/lib/types/client/update-api.d.ts +10 -0
  24. package/lib/types/client/update-api.d.ts.map +1 -1
  25. package/lib/types/client/update-api.js +22 -2
  26. package/lib/types/index.d.ts +7 -0
  27. package/lib/types/index.d.ts.map +1 -1
  28. package/lib/types/index.js +7 -1
  29. package/lib/types/mobile/api.d.ts +9 -2
  30. package/lib/types/mobile/api.d.ts.map +1 -1
  31. package/lib/types/mobile/api.js +7 -3
  32. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  33. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  34. package/lib/types/mobile/mobile-styles.js +3 -0
  35. package/lib/types/mobile/views/App.d.ts +3 -1
  36. package/lib/types/mobile/views/App.d.ts.map +1 -1
  37. package/lib/types/mobile/views/App.js +18 -3
  38. package/lib/types/mobile/views/ChatView.d.ts +6 -0
  39. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  40. package/lib/types/mobile/views/ChatView.js +95 -7
  41. package/lib/types/mobile/views/ChatView.test.js +161 -3
  42. package/lib/types/mobile/views/WorkspaceView.d.ts +3 -1
  43. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  44. package/lib/types/mobile/views/WorkspaceView.js +9 -2
  45. package/lib/types/mobile/views/WorkspaceView.test.d.ts +2 -0
  46. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +1 -0
  47. package/lib/types/mobile/views/WorkspaceView.test.js +56 -0
  48. package/lib/types/mobile-api.d.ts +2 -0
  49. package/lib/types/mobile-api.d.ts.map +1 -1
  50. package/lib/types/mobile-api.js +17 -2
  51. package/package.json +2 -12
  52. package/src/client/PluginSettingsCard.tsx +30 -5
  53. package/src/client/RemoteSettingsCard.tsx +18 -0
  54. package/src/client/UpdateEntry.tsx +10 -2
  55. package/src/client/deep-link.ts +10 -4
  56. package/src/client/index.ts +15 -2
  57. package/src/client/locales.ts +8 -0
  58. package/src/client/settings-card.module.css +139 -144
  59. package/src/client/update-api.ts +22 -2
  60. package/src/index.ts +14 -1
  61. package/src/mobile/api.ts +14 -2
  62. package/src/mobile/mobile-styles.ts +3 -0
  63. package/src/mobile/views/App.tsx +22 -3
  64. package/src/mobile/views/ChatView.test.tsx +185 -3
  65. package/src/mobile/views/ChatView.tsx +102 -8
  66. package/src/mobile/views/WorkspaceView.test.tsx +66 -0
  67. package/src/mobile/views/WorkspaceView.tsx +11 -2
  68. package/src/mobile-api.ts +20 -2
@@ -25,6 +25,8 @@ export interface RenderMessage {
25
25
  failed?: boolean;
26
26
  toolSummary?: string;
27
27
  }
28
+ /** Read the optional workspace target carried from the pairing QR flow. */
29
+ export declare function mobileWorkspaceTarget(search: string): string | undefined;
28
30
  /** Map a list row to the surface model; the title comes from projections when present. */
29
31
  export declare function toSessionView(item: {
30
32
  sessionId: string;
@@ -54,6 +56,6 @@ export declare function errorText(error: unknown): string;
54
56
  */
55
57
  export declare function staleHostHint(message: string): string | undefined;
56
58
  /** Fetch one history page (tail by default) — thin wrapper so views share the call shape. */
57
- export declare function loadHistory(sessionId: string, beforeSeq?: number): Promise<import("../api.ts").HistoryPage>;
59
+ export declare function loadHistory(sessionId: string, beforeSeq?: number, signal?: AbortSignal): Promise<import("../api.ts").HistoryPage>;
58
60
  export { listSessions, listWorkspaces, prompt };
59
61
  //# sourceMappingURL=App.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"App.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/App.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAA2B,YAAY,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAazF,yDAAyD;AACzD,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;CACf;AAED,wDAAwD;AACxD,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAClC,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAA;CACnD,GAAG,WAAW,CAad;AAED,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CASlD;AAED;;;GAGG;AACH,wBAAgB,GAAG,gCA+ClB;AAED,qEAAqE;AACrE,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAKhD;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAIjE;AAED,6FAA6F;AAC7F,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,4CAEhE;AAED,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,EAAE,CAAA"}
1
+ {"version":3,"file":"App.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/App.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAA2B,YAAY,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,WAAW,CAAA;AAazF,yDAAyD;AACzD,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;CACf;AAED,wDAAwD;AACxD,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,2EAA2E;AAC3E,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGxE;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAClC,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,OAAO,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAA;CACnD,GAAG,WAAW,CAad;AAED,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CASlD;AAED;;;GAGG;AACH,wBAAgB,GAAG,gCA4DlB;AAED,qEAAqE;AACrE,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAKhD;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAIjE;AAED,6FAA6F;AAC7F,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,4CAEtF;AAED,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,EAAE,CAAA"}
@@ -12,6 +12,11 @@ import { RpcCallError, RpcTransportError } from "../rpc.js";
12
12
  import { ChatView } from "./ChatView.js";
13
13
  import { SessionListView } from "./SessionListView.js";
14
14
  import { WorkspaceView as WorkspaceRoster } from "./WorkspaceView.js";
15
+ /** Read the optional workspace target carried from the pairing QR flow. */
16
+ export function mobileWorkspaceTarget(search) {
17
+ const value = new URLSearchParams(search).get('workspace');
18
+ return value === null || value === '' ? undefined : value;
19
+ }
15
20
  /** Map a list row to the surface model; the title comes from projections when present. */
16
21
  export function toSessionView(item) {
17
22
  const titleValue = item.projections?.values?.title;
@@ -46,6 +51,7 @@ export function formatTime(epochMs) {
46
51
  */
47
52
  export function App() {
48
53
  const [route, setRoute] = useState({ kind: 'workspaces' });
54
+ const [initialWorkspaceId, setInitialWorkspaceId] = useState(() => mobileWorkspaceTarget(window.location.search));
49
55
  const muxRef = useRef(undefined);
50
56
  // The mux stream lives for the page lifetime: session events keep the
51
57
  // open chat live, and reconnect is automatic.
@@ -73,8 +79,17 @@ export function App() {
73
79
  const openChat = useCallback((session, workspace) => {
74
80
  setRoute({ kind: 'chat', session, workspace });
75
81
  }, []);
82
+ const openWorkspace = useCallback((workspace) => {
83
+ if (initialWorkspaceId !== undefined) {
84
+ const url = new URL(window.location.href);
85
+ url.searchParams.delete('workspace');
86
+ window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
87
+ setInitialWorkspaceId(undefined);
88
+ }
89
+ setRoute({ kind: 'sessions', workspace });
90
+ }, [initialWorkspaceId]);
76
91
  return (_jsx("div", { className: "mobile", children: route.kind === 'workspaces'
77
- ? _jsx(WorkspaceRoster, { onPick: (workspace) => { setRoute({ kind: 'sessions', workspace }); } })
92
+ ? _jsx(WorkspaceRoster, { initialWorkspaceId: initialWorkspaceId, onPick: openWorkspace })
78
93
  : route.kind === 'sessions'
79
94
  ? (_jsx(SessionListView, { workspace: route.workspace, onBack: back, onPick: (session) => { openChat(session, route.workspace); } }))
80
95
  : _jsx(ChatView, { session: route.session, mux: muxRef.current, onBack: back }) }));
@@ -102,7 +117,7 @@ export function staleHostHint(message) {
102
117
  : undefined;
103
118
  }
104
119
  /** Fetch one history page (tail by default) — thin wrapper so views share the call shape. */
105
- export function loadHistory(sessionId, beforeSeq) {
106
- return fetchHistory(sessionId, beforeSeq);
120
+ export function loadHistory(sessionId, beforeSeq, signal) {
121
+ return fetchHistory(sessionId, beforeSeq, undefined, signal);
107
122
  }
108
123
  export { listSessions, listWorkspaces, prompt };
@@ -19,6 +19,12 @@ export interface ChatViewProps {
19
19
  mux?: MuxClient | undefined;
20
20
  onBack(): void;
21
21
  }
22
+ /**
23
+ * Hard cap on live events buffered while the initial history tail page is in
24
+ * flight. Beyond this the oldest buffered event is dropped and a follow-up
25
+ * history tail re-pull closes the seam.
26
+ */
27
+ export declare const MAX_TAIL_BUFFER_EVENTS = 500;
22
28
  /** One switchable permission preset (the `permissions` projection shape). */
23
29
  export interface PermissionOption {
24
30
  value: string;
@@ -1 +1 @@
1
- {"version":3,"file":"ChatView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/ChatView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA;AAIjE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAGrC,+BAA+B;AAC/B,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,WAAW,CAAA;IACpB,6EAA6E;IAC7E,GAAG,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IAC3B,MAAM,IAAI,IAAI,CAAA;CACf;AAYD,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,YAAY,EAAE,MAAM,CAAA;CACrB;AAqCD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,aAAa,+BA2N/D"}
1
+ {"version":3,"file":"ChatView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/ChatView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA;AAIjE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAGrC,+BAA+B;AAC/B,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,WAAW,CAAA;IACpB,6EAA6E;IAC7E,GAAG,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IAC3B,MAAM,IAAI,IAAI,CAAA;CACf;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAA;AAYzC,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,YAAY,EAAE,MAAM,CAAA;CACrB;AAqCD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,aAAa,+BAkT/D"}
@@ -14,9 +14,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
14
  import { useCallback, useEffect, useRef, useState } from 'react';
15
15
  import { loadHistory, prompt } from "./App.js";
16
16
  import { errorText, formatTime, staleHostHint } from "./App.js";
17
- import { models, selectModel, sendCommand } from "../api.js";
17
+ import { fetchMobilePreferences, models, selectModel, sendCommand } from "../api.js";
18
18
  import { foldEvents } from "../messages.js";
19
19
  import { ThemeToggle } from "../theme-toggle.js";
20
+ /**
21
+ * Hard cap on live events buffered while the initial history tail page is in
22
+ * flight. Beyond this the oldest buffered event is dropped and a follow-up
23
+ * history tail re-pull closes the seam.
24
+ */
25
+ export const MAX_TAIL_BUFFER_EVENTS = 500;
20
26
  /** Extract the raw event from one history entry (the fold consumes events only). */
21
27
  function eventOf(entry) {
22
28
  return entry.event;
@@ -77,22 +83,63 @@ export function ChatView({ session, mux, onBack }) {
77
83
  const [sending, setSending] = useState(false);
78
84
  const scrollRef = useRef(undefined);
79
85
  const pendingRef = useRef(false);
86
+ /**
87
+ * True while the initial tail page is in flight. Live events arriving in
88
+ * that window go to {@link liveBufferRef} instead of the message list: the
89
+ * tail load replaces the list wholesale, so a directly folded event would
90
+ * flash once, be discarded by the snapshot, and then be skipped forever by
91
+ * the seq watermark.
92
+ */
93
+ const tailLoadingRef = useRef(true);
94
+ /** Live session events buffered while the initial tail page loads. */
95
+ const liveBufferRef = useRef([]);
96
+ /** True once the live buffer hit its cap (oldest events were dropped). */
97
+ const liveBufferOverflowRef = useRef(false);
80
98
  /** The session's permission select (absent = capability not composed). */
81
99
  const [permissions, setPermissions] = useState(undefined);
82
100
  /** The current model selection for the toolbar chip (best-effort label). */
83
101
  const [currentModel, setCurrentModel] = useState(undefined);
84
102
  /** Which bottom sheet is open. */
85
103
  const [sheet, setSheet] = useState(null);
104
+ /**
105
+ * Composer preference from the plugin's host settings (default true keeps
106
+ * the legacy Enter-to-send behavior until the preference loads).
107
+ */
108
+ const [mobileEnterToSend, setMobileEnterToSend] = useState(true);
109
+ // Read-only mobile display preferences ride the plugin's local
110
+ // `/m/api` method; a failure keeps the default (Enter sends).
111
+ useEffect(() => {
112
+ let cancelled = false;
113
+ void fetchMobilePreferences().then((preferences) => {
114
+ if (!cancelled)
115
+ setMobileEnterToSend(preferences.mobileEnterToSend !== false);
116
+ }, () => { });
117
+ return () => { cancelled = true; };
118
+ }, []);
86
119
  // Tail page on open (content loads only when the session is opened).
87
120
  useEffect(() => {
88
121
  let cancelled = false;
122
+ const controller = new AbortController();
123
+ // A stuck history load must not keep the chat empty (or the live buffer
124
+ // growing) forever: abort it and surface the transport error.
125
+ const timeout = setTimeout(() => {
126
+ controller.abort(new DOMException('history load timed out', 'TimeoutError'));
127
+ }, 15_000);
128
+ tailLoadingRef.current = true;
129
+ liveBufferRef.current = [];
130
+ liveBufferOverflowRef.current = false;
89
131
  setLoading(true);
90
132
  setError(undefined);
91
133
  setMessages([]);
92
- void loadHistory(session.sessionId).then((page) => {
134
+ void loadHistory(session.sessionId, undefined, controller.signal).then((page) => {
93
135
  if (cancelled)
94
136
  return;
95
- setMessages(foldEvents(page.events.map(eventOf)));
137
+ // Buffered live events re-fold on top of the snapshot; the watermark
138
+ // drops any the snapshot already includes, so nothing is lost or doubled.
139
+ const buffered = liveBufferRef.current;
140
+ liveBufferRef.current = [];
141
+ tailLoadingRef.current = false;
142
+ setMessages(foldEvents(buffered, foldEvents(page.events.map(eventOf))));
96
143
  setHasOlder(page.hasMore);
97
144
  setLoading(false);
98
145
  // The history-tail projection baseline seeds the permission picker.
@@ -100,9 +147,31 @@ export function ChatView({ session, mux, onBack }) {
100
147
  // plugin (augmentation), so the base SDK map is indexed loosely.
101
148
  const projections = page.projections?.values;
102
149
  setPermissions(parsePermissionSelect(projections?.['permissions']));
150
+ // The buffer overflowed while waiting (oldest events were dropped), so
151
+ // re-pull the freshest history page to close the gap on top of what is
152
+ // already rendered. Best-effort: a failure here only logs, it must not
153
+ // replace the loaded state with an error.
154
+ if (liveBufferOverflowRef.current) {
155
+ void loadHistory(session.sessionId, undefined, controller.signal).then((fresh) => {
156
+ if (cancelled)
157
+ return;
158
+ setMessages(previous => foldEvents(fresh.events.map(eventOf), previous));
159
+ liveBufferOverflowRef.current = false;
160
+ }, (reason) => {
161
+ if (cancelled)
162
+ return;
163
+ console.warn('history tail re-pull after live buffer overflow failed', reason);
164
+ });
165
+ }
103
166
  }, (reason) => {
104
167
  if (cancelled)
105
168
  return;
169
+ // Load failed: flush the buffer so the live stream still renders.
170
+ const buffered = liveBufferRef.current;
171
+ liveBufferRef.current = [];
172
+ tailLoadingRef.current = false;
173
+ if (buffered.length > 0)
174
+ setMessages(foldEvents(buffered));
106
175
  setError(errorText(reason));
107
176
  setLoading(false);
108
177
  });
@@ -112,7 +181,11 @@ export function ChatView({ session, mux, onBack }) {
112
181
  if (!cancelled)
113
182
  setCurrentModel(directory.current);
114
183
  }, () => { });
115
- return () => { cancelled = true; };
184
+ return () => {
185
+ cancelled = true;
186
+ clearTimeout(timeout);
187
+ controller.abort();
188
+ };
116
189
  }, [session.sessionId]);
117
190
  // Live frames: fold session events for this session in as they arrive.
118
191
  useEffect(() => {
@@ -122,7 +195,22 @@ export function ChatView({ session, mux, onBack }) {
122
195
  if (frame.type === 'session/event') {
123
196
  if (frame.sessionId !== session.sessionId)
124
197
  return;
125
- setMessages(previous => foldEvents([frame.event], previous));
198
+ const event = frame.event;
199
+ if (tailLoadingRef.current) {
200
+ if (liveBufferRef.current.length >= MAX_TAIL_BUFFER_EVENTS) {
201
+ // Bound the tail-load window: drop the oldest buffered event and
202
+ // remember that a follow-up history re-pull is needed. Warn once
203
+ // per load, not for every subsequent overflow.
204
+ liveBufferRef.current.shift();
205
+ if (!liveBufferOverflowRef.current) {
206
+ console.warn(`history tail is slow: live buffer reached ${MAX_TAIL_BUFFER_EVENTS} events; oldest buffered events will be re-fetched`);
207
+ liveBufferOverflowRef.current = true;
208
+ }
209
+ }
210
+ liveBufferRef.current.push(event);
211
+ return;
212
+ }
213
+ setMessages(previous => foldEvents([event], previous));
126
214
  return;
127
215
  }
128
216
  // Live projection pushes keep the permission picker current.
@@ -204,8 +292,8 @@ export function ChatView({ session, mux, onBack }) {
204
292
  ? undefined
205
293
  : permissions.options.find(option => option.value === permissions.currentValue)?.name
206
294
  ?? displayName(permissions.currentValue);
207
- return (_jsxs("div", { className: "chat", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("button", { type: "button", className: "mobile-back", "aria-label": "\u8FD4\u56DE", onClick: onBack, children: "\u2039" }), _jsx("h1", { className: "mobile-title mobile-titleInline", children: session.title }), _jsx(ThemeToggle, {})] }), error !== undefined && _jsx("p", { className: "mobile-error mobile-pad", children: error }), _jsxs("div", { className: "chat-scroll", ref: ref => { scrollRef.current = ref ?? undefined; }, children: [hasOlder && (_jsx("button", { type: "button", className: "chat-load-older", disabled: loading, onClick: () => { void loadOlder(); }, children: loading ? '加载中…' : '加载更早的消息' })), messages.map(message => _jsx(MessageRow, { message: message }, message.id)), loading && messages.length === 0 && _jsx("p", { className: "chat-typing", children: "\u52A0\u8F7D\u4E2D\u2026" }), !loading && messages.length === 0 && _jsx("p", { className: "chat-typing", children: "\u8FD8\u6CA1\u6709\u6D88\u606F\uFF0C\u53D1\u4E00\u53E5\u8BDD\u5F00\u59CB\u5427" })] }), _jsxs("div", { className: "chat-tools", children: [_jsxs("button", { type: "button", className: "chat-chip", onClick: () => { setSheet('model'); }, "aria-haspopup": "dialog", children: [_jsx("span", { className: "chat-chip-label", children: "\u6A21\u578B" }), _jsx("span", { className: "chat-chip-value", children: modelLabel }), _jsx("span", { className: "chat-chip-chevron", "aria-hidden": true, children: "\u203A" })] }), permissionLabel !== undefined && (_jsxs("button", { type: "button", className: "chat-chip", onClick: () => { setSheet('permission'); }, "aria-haspopup": "dialog", children: [_jsx("span", { className: "chat-chip-label", children: "\u6743\u9650" }), _jsx("span", { className: "chat-chip-value", children: permissionLabel }), _jsx("span", { className: "chat-chip-chevron", "aria-hidden": true, children: "\u203A" })] }))] }), _jsxs("div", { className: "chat-inputbar", children: [_jsx("textarea", { className: "chat-input", rows: 1, value: input, placeholder: "\u8F93\u5165\u6D88\u606F\uFF0CEnter \u53D1\u9001\u2026", enterKeyHint: "send", onChange: (event) => { setInput(event.target.value); }, onKeyDown: (event) => {
208
- if (event.key === 'Enter' && !event.shiftKey) {
295
+ return (_jsxs("div", { className: "chat", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("button", { type: "button", className: "mobile-back", "aria-label": "\u8FD4\u56DE", onClick: onBack, children: "\u2039" }), _jsx("h1", { className: "mobile-title mobile-titleInline", children: session.title }), _jsx(ThemeToggle, {})] }), error !== undefined && _jsx("p", { className: "mobile-error mobile-pad", children: error }), _jsxs("div", { className: "chat-scroll", ref: ref => { scrollRef.current = ref ?? undefined; }, children: [hasOlder && (_jsx("button", { type: "button", className: "chat-load-older", disabled: loading, onClick: () => { void loadOlder(); }, children: loading ? '加载中…' : '加载更早的消息' })), messages.map(message => _jsx(MessageRow, { message: message }, message.id)), loading && messages.length === 0 && _jsx("p", { className: "chat-typing", children: "\u52A0\u8F7D\u4E2D\u2026" }), !loading && messages.length === 0 && _jsx("p", { className: "chat-typing", children: "\u8FD8\u6CA1\u6709\u6D88\u606F\uFF0C\u53D1\u4E00\u53E5\u8BDD\u5F00\u59CB\u5427" })] }), _jsxs("div", { className: "chat-tools", children: [_jsxs("button", { type: "button", className: "chat-chip", onClick: () => { setSheet('model'); }, "aria-haspopup": "dialog", children: [_jsx("span", { className: "chat-chip-label", children: "\u6A21\u578B" }), _jsx("span", { className: "chat-chip-value", children: modelLabel }), _jsx("span", { className: "chat-chip-chevron", "aria-hidden": true, children: "\u203A" })] }), permissionLabel !== undefined && (_jsxs("button", { type: "button", className: "chat-chip", onClick: () => { setSheet('permission'); }, "aria-haspopup": "dialog", children: [_jsx("span", { className: "chat-chip-label", children: "\u6743\u9650" }), _jsx("span", { className: "chat-chip-value", children: permissionLabel }), _jsx("span", { className: "chat-chip-chevron", "aria-hidden": true, children: "\u203A" })] }))] }), _jsxs("div", { className: "chat-inputbar", children: [_jsx("textarea", { className: "chat-input", rows: 1, value: input, placeholder: mobileEnterToSend ? '输入消息,Enter 发送…' : '输入消息,Enter 换行,点按钮发送…', enterKeyHint: mobileEnterToSend ? 'send' : 'enter', onChange: (event) => { setInput(event.target.value); }, onKeyDown: (event) => {
296
+ if (mobileEnterToSend && event.key === 'Enter' && !event.shiftKey) {
209
297
  event.preventDefault();
210
298
  void send();
211
299
  }
@@ -3,10 +3,11 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  /** ChatView: collapsible message folds, toolbar chips, and the bottom sheets. */
4
4
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
6
- import { ChatView } from "./ChatView.js";
6
+ import { ChatView, MAX_TAIL_BUFFER_EVENTS } from "./ChatView.js";
7
7
  // The api module is fully mocked; App.tsx's history wrapper is overridden to
8
8
  // feed fixed history pages, its pure helpers (errorText / formatTime) stay real.
9
9
  vi.mock('../api.ts', () => ({
10
+ fetchMobilePreferences: vi.fn(),
10
11
  models: vi.fn(),
11
12
  selectModel: vi.fn(),
12
13
  sendCommand: vi.fn(),
@@ -19,8 +20,8 @@ vi.mock('./App.tsx', async (importOriginal) => {
19
20
  prompt: vi.fn(async () => { }),
20
21
  };
21
22
  });
22
- import { models, selectModel, sendCommand } from "../api.js";
23
- import { loadHistory } from "./App.js";
23
+ import { fetchMobilePreferences, models, selectModel, sendCommand } from "../api.js";
24
+ import { loadHistory, prompt } from "./App.js";
24
25
  const session = {
25
26
  sessionId: 's-1',
26
27
  title: '测试会话',
@@ -58,11 +59,15 @@ function turnEvents() {
58
59
  }, 5),
59
60
  ];
60
61
  }
62
+ const fetchMobilePreferencesMock = vi.mocked(fetchMobilePreferences);
61
63
  const modelsMock = vi.mocked(models);
62
64
  const selectModelMock = vi.mocked(selectModel);
63
65
  const sendCommandMock = vi.mocked(sendCommand);
64
66
  const loadHistoryMock = vi.mocked(loadHistory);
67
+ const promptMock = vi.mocked(prompt);
65
68
  beforeEach(() => {
69
+ fetchMobilePreferencesMock.mockResolvedValue({ mobileEnterToSend: true });
70
+ promptMock.mockResolvedValue(undefined);
66
71
  modelsMock.mockResolvedValue({
67
72
  current: { provider: 'fx', model: 'fx-1' },
68
73
  routable: true,
@@ -84,6 +89,7 @@ beforeEach(() => {
84
89
  afterEach(() => {
85
90
  cleanup();
86
91
  vi.clearAllMocks();
92
+ vi.restoreAllMocks();
87
93
  });
88
94
  describe('ChatView message folds', () => {
89
95
  it('hides reasoning behind a collapsed disclosure and expands on tap', async () => {
@@ -165,6 +171,102 @@ describe('ChatView message folds', () => {
165
171
  });
166
172
  });
167
173
  });
174
+ describe('ChatView initial-load race', () => {
175
+ /** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
176
+ class FakeMux {
177
+ listeners = new Set();
178
+ onFrame(listener) {
179
+ this.listeners.add(listener);
180
+ return () => { this.listeners.delete(listener); };
181
+ }
182
+ emit(frame) {
183
+ for (const listener of this.listeners)
184
+ listener(frame);
185
+ }
186
+ }
187
+ it('keeps live events that arrive while the tail page is still loading', async () => {
188
+ let resolveHistory = () => { };
189
+ loadHistoryMock.mockReturnValue(new Promise((resolve) => { resolveHistory = resolve; }));
190
+ const mux = new FakeMux();
191
+ render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
192
+ // A live turn starts before the snapshot resolves: chunk, tool call, final.
193
+ await act(async () => {
194
+ mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', text: '正在' } }, 6).event });
195
+ mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry('tool/call', { turn: 1, step: 0, callId: 'c9', name: 'bash', arguments: '{"cmd":"ls"}' }, 7).event });
196
+ mux.emit({ type: 'session/event', sessionId: 's-1', event: makeEntry('assistant/message', { turn: 1, step: 0, message: { id: 'a-9', role: 'assistant', content: [{ type: 'text', text: '实时新消息' }] } }, 8).event });
197
+ });
198
+ // The snapshot predates those events; resolving it must not drop them.
199
+ await act(async () => { resolveHistory(historyPage(turnEvents())); });
200
+ expect(await screen.findByText('实时新消息')).toBeTruthy();
201
+ // The history turn's tool disclosure plus the live one both render.
202
+ expect((await screen.findAllByRole('button', { name: /工具/ })).length).toBe(2);
203
+ });
204
+ it('caps the tail-load live buffer and re-pulls the history tail after an overflow', async () => {
205
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
206
+ let resolveHistory = () => { };
207
+ loadHistoryMock
208
+ .mockReturnValueOnce(new Promise((resolve) => { resolveHistory = resolve; }))
209
+ // The overflow follow-up load resolves a page newer than the buffered burst.
210
+ .mockResolvedValueOnce(historyPage([
211
+ makeEntry('assistant/message', {
212
+ id: 'a-refetch',
213
+ role: 'assistant',
214
+ content: [{ type: 'text', text: '补拉恢复' }],
215
+ }, 700),
216
+ ]));
217
+ const mux = new FakeMux();
218
+ render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
219
+ // 501 live final messages arrive before the snapshot resolves: the first one
220
+ // is dropped by the 500-event cap, the remaining 500 stay buffered.
221
+ await act(async () => {
222
+ for (let index = 0; index <= MAX_TAIL_BUFFER_EVENTS; index++) {
223
+ mux.emit({
224
+ type: 'session/event',
225
+ sessionId: 's-1',
226
+ event: makeEntry('assistant/message', {
227
+ id: `a-burst-${index}`,
228
+ role: 'assistant',
229
+ content: [{ type: 'text', text: `突发消息 ${index}` }],
230
+ }, 100 + index).event,
231
+ });
232
+ }
233
+ });
234
+ // The overflow is logged exactly once, and the cap mentions the limit.
235
+ expect(warnSpy).toHaveBeenCalledTimes(1);
236
+ expect(String(warnSpy.mock.calls[0]?.[0])).toContain(String(MAX_TAIL_BUFFER_EVENTS));
237
+ await act(async () => { resolveHistory(historyPage(turnEvents())); });
238
+ // The capped buffer keeps the render bounded: the dropped oldest burst
239
+ // message is gone, while the newest buffered one survives the snapshot fold.
240
+ expect(await screen.findByText('突发消息 500')).toBeTruthy();
241
+ expect(screen.queryByText('突发消息 0')).toBeNull();
242
+ // The overflow triggered exactly one follow-up tail load, still carrying the
243
+ // same abort signal as the initial load.
244
+ await waitFor(() => { expect(loadHistoryMock).toHaveBeenCalledTimes(2); });
245
+ expect(loadHistoryMock.mock.calls[1]?.[0]).toBe('s-1');
246
+ expect(loadHistoryMock.mock.calls[1]?.[1]).toBeUndefined();
247
+ expect(loadHistoryMock.mock.calls[1]?.[2]).toBeInstanceOf(AbortSignal);
248
+ expect(loadHistoryMock.mock.calls[1]?.[2]).toBe(loadHistoryMock.mock.calls[0]?.[2]);
249
+ // The re-pulled page folds into the already-rendered messages.
250
+ expect(await screen.findByText('补拉恢复')).toBeTruthy();
251
+ });
252
+ it('passes an AbortSignal to loadHistory and aborts it on unmount', async () => {
253
+ let capturedSignal;
254
+ loadHistoryMock.mockImplementation((_sessionId, _beforeSeq, signal) => {
255
+ capturedSignal = signal;
256
+ return Promise.resolve(historyPage(turnEvents()));
257
+ });
258
+ const view = render(_jsx(ChatView, { session: session, onBack: () => { } }));
259
+ expect(await screen.findByText('已完成修改')).toBeTruthy();
260
+ expect(loadHistoryMock).toHaveBeenCalledTimes(1);
261
+ expect(loadHistoryMock.mock.calls[0]?.[0]).toBe('s-1');
262
+ expect(loadHistoryMock.mock.calls[0]?.[1]).toBeUndefined();
263
+ expect(loadHistoryMock.mock.calls[0]?.[2]).toBeInstanceOf(AbortSignal);
264
+ expect(capturedSignal).toBeInstanceOf(AbortSignal);
265
+ expect(capturedSignal?.aborted).toBe(false);
266
+ view.unmount();
267
+ expect(capturedSignal?.aborted).toBe(true);
268
+ });
269
+ });
168
270
  describe('ChatView model sheet', () => {
169
271
  it('labels the toolbar chip with the current model and selects a new one', async () => {
170
272
  loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
@@ -213,6 +315,62 @@ describe('ChatView model sheet', () => {
213
315
  expect(await screen.findByText(/重启 dsh web/)).toBeTruthy();
214
316
  });
215
317
  });
318
+ describe('ChatView composer', () => {
319
+ const inputBox = () => screen.getByRole('textbox');
320
+ /** Dispatch one keydown through the React tree and return the real event. */
321
+ const pressEnter = (input, shiftKey = false) => {
322
+ const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, shiftKey });
323
+ input.dispatchEvent(event);
324
+ return event;
325
+ };
326
+ it('sends on Enter by default and keeps Shift+Enter inserting a newline', async () => {
327
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
328
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
329
+ await screen.findByText('已完成修改');
330
+ const input = inputBox();
331
+ expect(input.getAttribute('enterKeyHint')).toBe('send');
332
+ expect(input.getAttribute('placeholder')).toContain('Enter 发送');
333
+ fireEvent.change(input, { target: { value: '第一行' } });
334
+ const enter = pressEnter(input);
335
+ expect(enter.defaultPrevented).toBe(true);
336
+ await waitFor(() => {
337
+ expect(promptMock).toHaveBeenCalledWith('s-1', '第一行');
338
+ });
339
+ // Shift+Enter stays a newline gesture and never sends.
340
+ promptMock.mockClear();
341
+ const shifted = pressEnter(input, true);
342
+ expect(shifted.defaultPrevented).toBe(false);
343
+ expect(promptMock).not.toHaveBeenCalled();
344
+ });
345
+ it('inserts a newline on Enter and sends only from the button when the preference is false', async () => {
346
+ fetchMobilePreferencesMock.mockResolvedValue({ mobileEnterToSend: false });
347
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
348
+ render(_jsx(ChatView, { session: session, onBack: () => { } }));
349
+ await screen.findByText('已完成修改');
350
+ const input = inputBox();
351
+ await waitFor(() => { expect(input.getAttribute('enterKeyHint')).toBe('enter'); });
352
+ expect(input.getAttribute('placeholder')).not.toContain('Enter 发送');
353
+ // The handler no longer prevents Enter, so the browser's default inserts
354
+ // a newline (emulated here through the controlled value) and no send fires.
355
+ fireEvent.change(input, { target: { value: '第一行' } });
356
+ const enter = pressEnter(input);
357
+ expect(enter.defaultPrevented).toBe(false);
358
+ fireEvent.change(input, { target: { value: '第一行\n' } });
359
+ expect(input.value).toBe('第一行\n');
360
+ expect(promptMock).not.toHaveBeenCalled();
361
+ // The send button still sends the full multi-line draft.
362
+ fireEvent.change(input, { target: { value: '第一行\n第二行' } });
363
+ fireEvent.click(screen.getByRole('button', { name: '发送' }));
364
+ await waitFor(() => {
365
+ expect(promptMock).toHaveBeenCalledWith('s-1', '第一行\n第二行');
366
+ });
367
+ // Shift+Enter keeps inserting a newline in either mode.
368
+ promptMock.mockClear();
369
+ const shifted = pressEnter(input, true);
370
+ expect(shifted.defaultPrevented).toBe(false);
371
+ expect(promptMock).not.toHaveBeenCalled();
372
+ });
373
+ });
216
374
  describe('ChatView scrolling', () => {
217
375
  // Controllable scrollHeight + a write log for the chat-scroll element. The
218
376
  // accessors live on Element.prototype, so patching them here lets every
@@ -7,6 +7,8 @@
7
7
  import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace';
8
8
  /** Props for the workspace roster. */
9
9
  export interface WorkspaceViewProps {
10
+ /** Workspace carried by the pairing link; opened after the roster loads. */
11
+ initialWorkspaceId?: string;
10
12
  /** Open one workspace's session list. */
11
13
  onPick(workspace: WorkspaceRow): void;
12
14
  }
@@ -15,5 +17,5 @@ export interface WorkspaceViewProps {
15
17
  * @param props - the pick action.
16
18
  * @returns the roster.
17
19
  */
18
- export declare function WorkspaceView({ onPick }: WorkspaceViewProps): import("react").JSX.Element;
20
+ export declare function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps): import("react").JSX.Element;
19
21
  //# sourceMappingURL=WorkspaceView.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"WorkspaceView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/WorkspaceView.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,IAAI,YAAY,EAAE,MAAM,8CAA8C,CAAA;AAKjG,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IACjC,yCAAyC;IACzC,MAAM,CAAC,SAAS,EAAE,YAAY,GAAG,IAAI,CAAA;CACtC;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,EAAE,MAAM,EAAE,EAAE,kBAAkB,+BAmF3D"}
1
+ {"version":3,"file":"WorkspaceView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/WorkspaceView.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,IAAI,YAAY,EAAE,MAAM,8CAA8C,CAAA;AAKjG,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IACjC,4EAA4E;IAC5E,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,yCAAyC;IACzC,MAAM,CAAC,SAAS,EAAE,YAAY,GAAG,IAAI,CAAA;CACtC;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,EAAE,kBAAkB,EAAE,MAAM,EAAE,EAAE,kBAAkB,+BA0F/E"}
@@ -14,7 +14,7 @@ import { ThemeToggle } from "../theme-toggle.js";
14
14
  * @param props - the pick action.
15
15
  * @returns the roster.
16
16
  */
17
- export function WorkspaceView({ onPick }) {
17
+ export function WorkspaceView({ initialWorkspaceId, onPick }) {
18
18
  const [items, setItems] = useState(undefined);
19
19
  const [error, setError] = useState(undefined);
20
20
  useEffect(() => {
@@ -22,6 +22,13 @@ export function WorkspaceView({ onPick }) {
22
22
  void listWorkspaces().then((rows) => {
23
23
  if (cancelled)
24
24
  return;
25
+ const target = initialWorkspaceId === undefined
26
+ ? undefined
27
+ : rows.find(workspace => workspace.workspaceId === initialWorkspaceId);
28
+ if (target !== undefined) {
29
+ onPick(target);
30
+ return;
31
+ }
25
32
  setItems(rows);
26
33
  }, (reason) => {
27
34
  if (cancelled)
@@ -29,7 +36,7 @@ export function WorkspaceView({ onPick }) {
29
36
  setError(errorText(reason));
30
37
  });
31
38
  return () => { cancelled = true; };
32
- }, []);
39
+ }, [initialWorkspaceId, onPick]);
33
40
  if (error !== undefined) {
34
41
  return (_jsxs("div", { className: "mobile", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("h1", { className: "mobile-title", children: "\u5DE5\u4F5C\u533A" }), _jsx(ThemeToggle, {})] }), _jsxs("div", { className: "mobile-empty", children: [_jsxs("p", { className: "mobile-error", children: ["\u52A0\u8F7D\u5931\u8D25\uFF1A", error] }), _jsx("button", { type: "button", className: "mobile-button", onClick: () => { setError(undefined); setItems(undefined); }, children: "\u91CD\u8BD5" })] })] }));
35
42
  }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=WorkspaceView.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WorkspaceView.test.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/WorkspaceView.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // @vitest-environment jsdom
3
+ /** Mobile workspace landing: roster rendering and QR deep-link selection. */
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { cleanup, render, screen, waitFor } from '@testing-library/react';
6
+ import { mobileWorkspaceTarget } from "./App.js";
7
+ import { WorkspaceView } from "./WorkspaceView.js";
8
+ vi.mock('../api.ts', () => ({
9
+ listWorkspaces: vi.fn(),
10
+ }));
11
+ import { listWorkspaces } from "../api.js";
12
+ const listWorkspacesMock = vi.mocked(listWorkspaces);
13
+ const workspaces = [
14
+ {
15
+ workspaceId: 'ws-1',
16
+ path: '/tmp/first',
17
+ title: 'First',
18
+ sessionIds: [],
19
+ createdAt: '2026-01-01T00:00:00.000Z',
20
+ updatedAt: '2026-01-01T00:00:00.000Z',
21
+ },
22
+ {
23
+ workspaceId: 'ws-2',
24
+ path: '/tmp/second',
25
+ title: 'Second',
26
+ sessionIds: [],
27
+ createdAt: '2026-01-01T00:00:00.000Z',
28
+ updatedAt: '2026-01-01T00:00:00.000Z',
29
+ },
30
+ ];
31
+ afterEach(() => {
32
+ cleanup();
33
+ vi.clearAllMocks();
34
+ });
35
+ describe('mobile workspace deep link', () => {
36
+ it('reads a non-empty workspace target from the query', () => {
37
+ expect(mobileWorkspaceTarget('?workspace=ws-2')).toBe('ws-2');
38
+ expect(mobileWorkspaceTarget('?workspace=')).toBeUndefined();
39
+ expect(mobileWorkspaceTarget('')).toBeUndefined();
40
+ });
41
+ it('opens the targeted workspace as soon as the roster loads', async () => {
42
+ listWorkspacesMock.mockResolvedValue(workspaces);
43
+ const onPick = vi.fn();
44
+ render(_jsx(WorkspaceView, { initialWorkspaceId: "ws-2", onPick: onPick }));
45
+ await waitFor(() => expect(onPick).toHaveBeenCalledWith(workspaces[1]));
46
+ expect(onPick).toHaveBeenCalledTimes(1);
47
+ });
48
+ it('falls back to the roster when the target no longer exists', async () => {
49
+ listWorkspacesMock.mockResolvedValue(workspaces);
50
+ const onPick = vi.fn();
51
+ render(_jsx(WorkspaceView, { initialWorkspaceId: "missing", onPick: onPick }));
52
+ expect(await screen.findByText('First')).toBeTruthy();
53
+ expect(await screen.findByText('Second')).toBeTruthy();
54
+ expect(onPick).not.toHaveBeenCalled();
55
+ });
56
+ });
@@ -27,6 +27,8 @@ export interface MobileApiDeps {
27
27
  service: PairingService;
28
28
  /** The host ApiProxy service (injected by the plugin). */
29
29
  apiProxy: ApiProxy;
30
+ /** The resolved mobile composer preference (live per request). */
31
+ mobileEnterToSend: () => boolean;
30
32
  }
31
33
  /** Mobile API route paths. */
32
34
  export declare const MOBILE_API_PATHS: {
@@ -1 +1 @@
1
- {"version":3,"file":"mobile-api.d.ts","sourceRoot":"","sources":["../../src/mobile-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAA;AAG9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAuClD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,CAAA;CACnB;AAED,8BAA8B;AAC9B,eAAO,MAAM,gBAAgB;;CAEnB,CAAA;AAOV;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CAkHnE"}
1
+ {"version":3,"file":"mobile-api.d.ts","sourceRoot":"","sources":["../../src/mobile-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAA;AAG9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AA8ClD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,CAAA;IAClB,kEAAkE;IAClE,iBAAiB,EAAE,MAAM,OAAO,CAAA;CACjC;AAED,8BAA8B;AAC9B,eAAO,MAAM,gBAAgB;;CAEnB,CAAA;AAOV;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CA2HnE"}