@dotdrelle/wiki-manager 0.11.0 → 0.11.6
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/README.md +14 -0
- package/agents.docker-compose.yml +3 -3
- package/package.json +2 -2
- package/src/agent/graph.js +99 -3
- package/src/agent/graph.test.js +81 -0
- package/src/cli/wiki-manager.js +11 -4
- package/src/commands/slash.js +7 -1
- package/src/core/agentLoop.js +20 -5
- package/src/core/agentLoop.test.js +19 -1
- package/src/core/mcp.js +1 -1
- package/src/core/planPatch.js +39 -1
- package/src/core/planPatch.test.js +23 -1
- package/src/core/wikiSetup.js +7 -2
- package/src/core/wikirc.test.js +40 -2
- package/src/runtime/client.js +12 -0
- package/src/runtime/donna-contract.test.js +359 -0
- package/src/runtime/lifecycle.js +1 -1
- package/src/runtime/runner.e2e.test.js +52 -0
- package/src/runtime/runner.js +126 -8
- package/src/runtime/runner.test.js +385 -3
- package/src/runtime/server.js +28 -2
- package/src/runtime/server.test.js +38 -0
- package/src/runtime/store.js +72 -2
- package/src/runtime/store.test.js +198 -1
- package/src/shell/LeftPane.tsx +2 -1
- package/src/shell/StartupScreen.tsx +101 -21
- package/src/shell/repl.js +60 -15
- package/src/shell/repl.test.js +33 -1
- package/src/shell/tui.tsx +90 -75
- package/src/shell/useAgent.ts +14 -4
- package/src/shell/useSession.ts +92 -17
package/src/shell/repl.test.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
applyRuntimeStateToShellSession,
|
|
5
|
+
createSession,
|
|
6
|
+
conversationMessages,
|
|
7
|
+
recordRuntimeUnavailableAgentInput,
|
|
8
|
+
runtimeStatusLine,
|
|
9
|
+
runtimeUnavailableAgentMessage,
|
|
10
|
+
submitRuntimeRun,
|
|
11
|
+
} from './repl.js';
|
|
4
12
|
|
|
5
13
|
function stubFetch(handler) {
|
|
6
14
|
const original = globalThis.fetch;
|
|
@@ -112,3 +120,27 @@ test('submitRuntimeRun reports a non-409 error without throwing or calling /cont
|
|
|
112
120
|
restore();
|
|
113
121
|
}
|
|
114
122
|
});
|
|
123
|
+
|
|
124
|
+
test('agent mode without runtime records a visible error instead of falling back locally', () => {
|
|
125
|
+
const session = createSession();
|
|
126
|
+
session.chatMode = false;
|
|
127
|
+
|
|
128
|
+
const message = recordRuntimeUnavailableAgentInput(session, 'salut', { error: 'port 7788 already in use' });
|
|
129
|
+
|
|
130
|
+
assert.equal(message, '⚠ Runtime indisponible : port 7788 already in use — /agent désactivé, /chat reste possible');
|
|
131
|
+
assert.deepEqual(conversationMessages(session), [
|
|
132
|
+
{ role: 'user', content: 'salut' },
|
|
133
|
+
{ role: 'command', content: message },
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('runtime status exposes the disconnected reason', () => {
|
|
138
|
+
assert.equal(
|
|
139
|
+
runtimeStatusLine({ error: 'token mismatch' }, { workspace: 'juno' }),
|
|
140
|
+
'runtime: disconnected: token mismatch',
|
|
141
|
+
);
|
|
142
|
+
assert.equal(
|
|
143
|
+
runtimeUnavailableAgentMessage({ error: 'token mismatch' }),
|
|
144
|
+
'⚠ Runtime indisponible : token mismatch — /agent désactivé, /chat reste possible',
|
|
145
|
+
);
|
|
146
|
+
});
|
package/src/shell/tui.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { render, useKeyboard, useRenderer, useSelectionHandler, useTerminalDimensions } from '@opentui/solid';
|
|
4
|
-
import { createMemo, createSignal, onCleanup } from 'solid-js';
|
|
4
|
+
import { createMemo, createSignal, onCleanup, Show } from 'solid-js';
|
|
5
5
|
import { FileEditorDialog } from './FileEditorDialog';
|
|
6
6
|
import { LeftPane } from './LeftPane';
|
|
7
7
|
import { RightPane } from './RightPane';
|
|
@@ -12,6 +12,7 @@ import { useSession } from './useSession';
|
|
|
12
12
|
import { buildMcpStatus } from '../core/mcp.js';
|
|
13
13
|
import { loadWikircProfile, summarizeWikircConfig } from '../core/wikirc.js';
|
|
14
14
|
import { listWorkspaces } from '../core/workspaces.js';
|
|
15
|
+
import { postRuntimeShutdown } from '../runtime/client.js';
|
|
15
16
|
|
|
16
17
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
17
18
|
|
|
@@ -109,6 +110,7 @@ function App(props: {
|
|
|
109
110
|
const [exitHint, setExitHint] = createSignal(false);
|
|
110
111
|
const [copyHint, setCopyHint] = createSignal<string | null>(null);
|
|
111
112
|
const [chatInputHeight, setChatInputHeight] = createSignal(3);
|
|
113
|
+
const [startupKeyboardEvent, setStartupKeyboardEvent] = createSignal<{ id: number; key: any } | null>(null);
|
|
112
114
|
// The app has exactly three mutually exclusive screens; one signal makes
|
|
113
115
|
// that invariant structural instead of relying on two booleans staying in
|
|
114
116
|
// sync at every call site.
|
|
@@ -116,7 +118,9 @@ function App(props: {
|
|
|
116
118
|
let ctrlCTimer: ReturnType<typeof setTimeout> | null = null;
|
|
117
119
|
let copyHintTimer: ReturnType<typeof setTimeout> | null = null;
|
|
118
120
|
let selectionCopyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
121
|
+
let startupKeyboardEventId = 0;
|
|
119
122
|
let lastCopiedSelection = '';
|
|
123
|
+
let runtimeShutdownRequested = false;
|
|
120
124
|
const state = useSession(props);
|
|
121
125
|
const startup = createMemo(() => startupInfo(props.packageJson));
|
|
122
126
|
const conversationRows = createMemo(() => Math.max(4, dimensions().height - 5 - chatInputHeight()));
|
|
@@ -224,6 +228,10 @@ function App(props: {
|
|
|
224
228
|
onCleanup(() => {
|
|
225
229
|
if (copyHintTimer) clearTimeout(copyHintTimer);
|
|
226
230
|
if (selectionCopyTimer) clearTimeout(selectionCopyTimer);
|
|
231
|
+
if (props.runtime?.url && !runtimeShutdownRequested) {
|
|
232
|
+
runtimeShutdownRequested = true;
|
|
233
|
+
void postRuntimeShutdown({ url: props.runtime.url }).catch(() => {});
|
|
234
|
+
}
|
|
227
235
|
});
|
|
228
236
|
|
|
229
237
|
const spinnerTimer = setInterval(() => {
|
|
@@ -236,6 +244,11 @@ function App(props: {
|
|
|
236
244
|
|
|
237
245
|
useKeyboard((key) => {
|
|
238
246
|
const keyName = String(key.name ?? '').toLowerCase();
|
|
247
|
+
if (screen() === 'startup') {
|
|
248
|
+
startupKeyboardEventId += 1;
|
|
249
|
+
setStartupKeyboardEvent({ id: startupKeyboardEventId, key });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
239
252
|
if (screen() !== 'main') return;
|
|
240
253
|
if (state.activeEditor()) {
|
|
241
254
|
if (keyName === 'escape') state.closeEditor();
|
|
@@ -279,89 +292,91 @@ function App(props: {
|
|
|
279
292
|
const hintLine = () => {
|
|
280
293
|
if (copyHint()) return copyHint();
|
|
281
294
|
if (exitHint()) return 'Press Ctrl+C again to exit.';
|
|
295
|
+
if (state.runtimeHint()) return state.runtimeHint();
|
|
282
296
|
return null;
|
|
283
297
|
};
|
|
284
298
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
299
|
+
return (
|
|
300
|
+
<Show
|
|
301
|
+
when={screen() === 'startup'}
|
|
302
|
+
fallback={
|
|
303
|
+
<box width="100%" height="100%" flexDirection="row">
|
|
304
|
+
<LeftPane
|
|
305
|
+
width={leftColumns()}
|
|
306
|
+
title={state.title()}
|
|
307
|
+
statusLine={state.statusLine()}
|
|
308
|
+
hintLine={hintLine()}
|
|
309
|
+
showWelcome={state.showWelcome()}
|
|
310
|
+
messages={state.messages()}
|
|
311
|
+
prompt={state.prompt()}
|
|
312
|
+
input={state.input()}
|
|
313
|
+
busy={state.busy()}
|
|
314
|
+
chatMode={state.chatMode()}
|
|
315
|
+
chatFocused={!state.activeEditor()}
|
|
316
|
+
setInput={state.setInput}
|
|
317
|
+
submit={submit}
|
|
318
|
+
conversationRows={conversationRows()}
|
|
319
|
+
conversationColumns={conversationColumns()}
|
|
320
|
+
conversationScroll={state.conversationScroll()}
|
|
321
|
+
scrollConversation={state.scrollConversation}
|
|
322
|
+
spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
|
|
323
|
+
onInputHeightChange={setChatInputHeight}
|
|
324
|
+
onCopy={(content) => showCopyHint(copyToClipboard(content, renderer) ? 'Copied.' : 'Copy failed.')}
|
|
325
|
+
/>
|
|
326
|
+
<box width={1} height="100%" flexDirection="column">
|
|
327
|
+
{Array.from({ length: dimensions().height }, () => (
|
|
328
|
+
<text fg="#4B5563">│</text>
|
|
329
|
+
))}
|
|
330
|
+
</box>
|
|
331
|
+
<RightPane
|
|
332
|
+
width={rightColumns()}
|
|
333
|
+
activities={state.activities()}
|
|
334
|
+
logs={state.logs()}
|
|
335
|
+
plan={state.plan()}
|
|
336
|
+
queueItems={state.queueItems()}
|
|
337
|
+
queueInfo={state.queueInfo()}
|
|
338
|
+
activeTab={state.rightTab()}
|
|
339
|
+
onTabClick={state.selectRightTab}
|
|
340
|
+
/>
|
|
341
|
+
<SlashDialog context={state.activeEditor() ? null : state.slash()} />
|
|
342
|
+
<FileEditorDialog
|
|
343
|
+
editor={state.activeEditor()}
|
|
344
|
+
width={dimensions().width}
|
|
345
|
+
height={dimensions().height}
|
|
346
|
+
onSave={state.saveEditor}
|
|
347
|
+
onCancel={state.closeEditor}
|
|
348
|
+
/>
|
|
349
|
+
{screen() === 'setup' ? (
|
|
350
|
+
<SetupWizard
|
|
351
|
+
mode="setup"
|
|
352
|
+
session={state.session}
|
|
353
|
+
width={dimensions().width}
|
|
354
|
+
height={dimensions().height}
|
|
355
|
+
initialRoute="workspace-name"
|
|
356
|
+
closeOnDone
|
|
357
|
+
onComplete={closeSetup}
|
|
358
|
+
onClose={closeSetup}
|
|
359
|
+
/>
|
|
360
|
+
) : null}
|
|
361
|
+
</box>
|
|
362
|
+
}
|
|
363
|
+
>
|
|
288
364
|
<StartupScreen
|
|
289
|
-
version={
|
|
290
|
-
model={
|
|
291
|
-
connectedMcpServers={
|
|
292
|
-
wikiReady={
|
|
293
|
-
workspaceName={
|
|
294
|
-
profileName={
|
|
295
|
-
workspaces={
|
|
296
|
-
hasWorkspace={
|
|
365
|
+
version={startup().version}
|
|
366
|
+
model={startup().model}
|
|
367
|
+
connectedMcpServers={startup().connectedMcpServers}
|
|
368
|
+
wikiReady={startup().wikiReady}
|
|
369
|
+
workspaceName={startup().workspaceName}
|
|
370
|
+
profileName={startup().profileName}
|
|
371
|
+
workspaces={startup().workspaces}
|
|
372
|
+
hasWorkspace={startup().hasWorkspace}
|
|
297
373
|
width={dimensions().width}
|
|
298
374
|
height={dimensions().height}
|
|
375
|
+
keyboardEvent={startupKeyboardEvent()}
|
|
299
376
|
onSelect={openAction}
|
|
300
377
|
onQuit={() => renderer.destroy()}
|
|
301
378
|
/>
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return (
|
|
306
|
-
<box width="100%" height="100%" flexDirection="row">
|
|
307
|
-
<LeftPane
|
|
308
|
-
width={leftColumns()}
|
|
309
|
-
title={state.title()}
|
|
310
|
-
statusLine={state.statusLine()}
|
|
311
|
-
hintLine={hintLine()}
|
|
312
|
-
showWelcome={state.showWelcome()}
|
|
313
|
-
messages={state.messages()}
|
|
314
|
-
prompt={state.prompt()}
|
|
315
|
-
input={state.input()}
|
|
316
|
-
busy={state.busy()}
|
|
317
|
-
chatMode={state.chatMode()}
|
|
318
|
-
chatFocused={!state.activeEditor()}
|
|
319
|
-
setInput={state.setInput}
|
|
320
|
-
submit={submit}
|
|
321
|
-
conversationRows={conversationRows()}
|
|
322
|
-
conversationColumns={conversationColumns()}
|
|
323
|
-
conversationScroll={state.conversationScroll()}
|
|
324
|
-
scrollConversation={state.scrollConversation}
|
|
325
|
-
spinnerFrame={SPINNER_FRAMES[spinnerIndex()] ?? SPINNER_FRAMES[0]}
|
|
326
|
-
onInputHeightChange={setChatInputHeight}
|
|
327
|
-
onCopy={(content) => showCopyHint(copyToClipboard(content, renderer) ? 'Copied.' : 'Copy failed.')}
|
|
328
|
-
/>
|
|
329
|
-
<box width={1} height="100%" flexDirection="column">
|
|
330
|
-
{Array.from({ length: dimensions().height }, () => (
|
|
331
|
-
<text fg="#4B5563">│</text>
|
|
332
|
-
))}
|
|
333
|
-
</box>
|
|
334
|
-
<RightPane
|
|
335
|
-
width={rightColumns()}
|
|
336
|
-
activities={state.activities()}
|
|
337
|
-
logs={state.logs()}
|
|
338
|
-
plan={state.plan()}
|
|
339
|
-
queueItems={state.queueItems()}
|
|
340
|
-
queueInfo={state.queueInfo()}
|
|
341
|
-
activeTab={state.rightTab()}
|
|
342
|
-
onTabClick={state.selectRightTab}
|
|
343
|
-
/>
|
|
344
|
-
<SlashDialog context={state.activeEditor() ? null : state.slash()} />
|
|
345
|
-
<FileEditorDialog
|
|
346
|
-
editor={state.activeEditor()}
|
|
347
|
-
width={dimensions().width}
|
|
348
|
-
height={dimensions().height}
|
|
349
|
-
onSave={state.saveEditor}
|
|
350
|
-
onCancel={state.closeEditor}
|
|
351
|
-
/>
|
|
352
|
-
{screen() === 'setup' ? (
|
|
353
|
-
<SetupWizard
|
|
354
|
-
mode="setup"
|
|
355
|
-
session={state.session}
|
|
356
|
-
width={dimensions().width}
|
|
357
|
-
height={dimensions().height}
|
|
358
|
-
initialRoute="workspace-name"
|
|
359
|
-
closeOnDone
|
|
360
|
-
onComplete={closeSetup}
|
|
361
|
-
onClose={closeSetup}
|
|
362
|
-
/>
|
|
363
|
-
) : null}
|
|
364
|
-
</box>
|
|
379
|
+
</Show>
|
|
365
380
|
);
|
|
366
381
|
}
|
|
367
382
|
|
package/src/shell/useAgent.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createSignal } from 'solid-js';
|
|
2
2
|
import { postRuntimeCancel } from '../runtime/client.js';
|
|
3
|
-
import { conversationMessages, runLine, submitRuntimeRun } from './repl.js';
|
|
3
|
+
import { conversationMessages, recordRuntimeUnavailableAgentInput, runLine, submitRuntimeRun } from './repl.js';
|
|
4
4
|
|
|
5
|
-
export function useAgent(props: { agent: unknown; packageJson: Record<string, unknown>; session: Record<string, any>; chatMode: () => boolean; runtimeUrl?: string | null; refresh: () => void; addLog: (line: string) => void; onRuntimeAccepted?: () => void }) {
|
|
5
|
+
export function useAgent(props: { agent: unknown; packageJson: Record<string, unknown>; session: Record<string, any>; chatMode: () => boolean; runtimeUrl?: string | null; runtimeUnavailableReason?: string | null; refresh: () => void; addLog: (line: string) => void; onRuntimeAccepted?: () => void }) {
|
|
6
6
|
const [busy, setBusy] = createSignal(false);
|
|
7
7
|
const [abortController, setAbortController] = createSignal<AbortController | null>(null);
|
|
8
8
|
|
|
@@ -19,13 +19,15 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
|
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
21
|
if (props.runtimeUrl && !props.chatMode() && !trimmed.startsWith('/')) {
|
|
22
|
-
|
|
22
|
+
// Marked _pending so mergeRuntimeConversation (useSession.ts) can
|
|
23
|
+
// confirm this exact entry instead of pushing a second copy once the
|
|
24
|
+
// same user message comes back from the runtime's own /state.
|
|
25
|
+
conversationMessages(props.session).push({ role: 'user', content: trimmed, _pending: true });
|
|
23
26
|
const outcome = await submitRuntimeRun(trimmed, {
|
|
24
27
|
runtime: { url: props.runtimeUrl },
|
|
25
28
|
session: props.session,
|
|
26
29
|
});
|
|
27
30
|
if (outcome.kind === 'accepted') {
|
|
28
|
-
conversationMessages(props.session).push({ role: 'command', content: `Runtime run queued: ${props.runtimeUrl}` });
|
|
29
31
|
props.onRuntimeAccepted?.();
|
|
30
32
|
props.addLog('runtime: run accepted');
|
|
31
33
|
} else if (outcome.kind === 'queued') {
|
|
@@ -38,6 +40,14 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
|
|
|
38
40
|
props.refresh();
|
|
39
41
|
return { exit: false, runtime: true };
|
|
40
42
|
}
|
|
43
|
+
if (!props.chatMode() && !trimmed.startsWith('/')) {
|
|
44
|
+
const message = recordRuntimeUnavailableAgentInput(props.session, trimmed, {
|
|
45
|
+
error: props.runtimeUnavailableReason ?? 'runtime introuvable',
|
|
46
|
+
});
|
|
47
|
+
props.addLog(message ?? 'runtime: disconnected');
|
|
48
|
+
props.refresh();
|
|
49
|
+
return { exit: false, runtimeUnavailable: true };
|
|
50
|
+
}
|
|
41
51
|
const result = await runLine(trimmed, {
|
|
42
52
|
agent: props.agent,
|
|
43
53
|
packageJson: props.packageJson,
|
package/src/shell/useSession.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
-
import { createMemo, createSignal, onCleanup } from 'solid-js';
|
|
3
|
+
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
|
4
4
|
import { formatMcpToolResult, callMcpTool } from '../core/mcp.js';
|
|
5
5
|
import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
6
6
|
import { formatPlanStatus, formatCompletedActivities } from '../core/plan.js';
|
|
@@ -14,12 +14,13 @@ import {
|
|
|
14
14
|
completionDescription,
|
|
15
15
|
conversationMessages,
|
|
16
16
|
createSession,
|
|
17
|
+
runtimeUnavailableAgentMessage,
|
|
17
18
|
} from './repl.js';
|
|
18
19
|
import { useAgent } from './useAgent';
|
|
19
20
|
|
|
20
|
-
function runtimeStatusText(status: 'disabled' | 'connected' | 'disconnected', runStatus: string): string {
|
|
21
|
-
if (status === 'connected') return `runtime ${runStatus}`;
|
|
22
|
-
if (status === 'disconnected') return 'runtime offline';
|
|
21
|
+
function runtimeStatusText(status: 'disabled' | 'connected' | 'disconnected', runStatus: string, reason: string | null, workspace: string | null): string {
|
|
22
|
+
if (status === 'connected') return `runtime: connected (${workspace ?? 'no workspace'}) ${runStatus}`;
|
|
23
|
+
if (status === 'disconnected') return reason ? `runtime: disconnected: ${reason}` : 'runtime offline';
|
|
23
24
|
return 'runtime off';
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -74,13 +75,21 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
74
75
|
const addLog = (line: string) => {
|
|
75
76
|
setLogs((items) => [...items, `${new Date().toLocaleTimeString()} ${line}`].slice(-200));
|
|
76
77
|
};
|
|
78
|
+
const runtimeUnavailableReason = createMemo(() => {
|
|
79
|
+
if (props.runtime?.url) return null;
|
|
80
|
+
const reason = props.runtime?.error ?? props.runtime?.unavailableReason ?? props.runtime?.reason ?? null;
|
|
81
|
+
return reason ? String(reason) : null;
|
|
82
|
+
});
|
|
77
83
|
if (props.runtime?.url) addLog(`runtime: connected ${props.runtime.url}${props.runtime.started ? ' (started)' : ''}`);
|
|
84
|
+
else if (props.runtime?.error) addLog(`runtime: unavailable — ${props.runtime.error}`);
|
|
85
|
+
else if (runtimeUnavailableReason()) addLog(`runtime: disconnected: ${runtimeUnavailableReason()}`);
|
|
78
86
|
const agent = useAgent({
|
|
79
87
|
agent: props.agent,
|
|
80
88
|
packageJson: props.packageJson,
|
|
81
89
|
session,
|
|
82
90
|
chatMode,
|
|
83
91
|
runtimeUrl: props.runtime?.url ?? null,
|
|
92
|
+
runtimeUnavailableReason: runtimeUnavailableReason(),
|
|
84
93
|
refresh,
|
|
85
94
|
addLog,
|
|
86
95
|
onRuntimeAccepted: () => {
|
|
@@ -97,23 +106,22 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
97
106
|
);
|
|
98
107
|
const localFallbackActive = createMemo(() => !props.runtime?.url || runtimeStatus() === 'disconnected');
|
|
99
108
|
|
|
109
|
+
// Runtime conversation entries are merged into conversationMessages(session)
|
|
110
|
+
// (see mergeRuntimeConversation below) as they arrive, so they land in true
|
|
111
|
+
// chronological order alongside local command output (/status, /use, ...)
|
|
112
|
+
// instead of being reconstructed as two separately-ordered blocks at render
|
|
113
|
+
// time — that used to put every local command before the whole runtime
|
|
114
|
+
// history regardless of when each one actually happened.
|
|
100
115
|
const messages = createMemo(() => {
|
|
101
116
|
version();
|
|
117
|
+
return [...conversationMessages(session)].slice(-200);
|
|
118
|
+
});
|
|
119
|
+
createEffect(() => {
|
|
102
120
|
const runtimeConversation = runtimeState()?.conversation;
|
|
103
|
-
const localCommands = conversationMessages(session)
|
|
104
|
-
.filter((message: any) => message.role === 'command')
|
|
105
|
-
.map((message: any) => ({
|
|
106
|
-
role: 'command',
|
|
107
|
-
content: String(message.content ?? ''),
|
|
108
|
-
}));
|
|
109
121
|
if (Array.isArray(runtimeConversation) && runtimeConversation.length > 0) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
content: String(message.content ?? ''),
|
|
113
|
-
}));
|
|
114
|
-
return [...runtimeMessages, ...localCommands].slice(-200);
|
|
122
|
+
setShowWelcome(false);
|
|
123
|
+
setConversationScroll(0);
|
|
115
124
|
}
|
|
116
|
-
return [...conversationMessages(session)];
|
|
117
125
|
});
|
|
118
126
|
const prompt = createMemo(() => {
|
|
119
127
|
version();
|
|
@@ -133,9 +141,14 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
133
141
|
session.wikirc?.profile ? session.wikirc.profile : 'no wikirc',
|
|
134
142
|
session.language ? session.language : 'no language',
|
|
135
143
|
session.llm ? 'llm ready' : 'llm limited',
|
|
136
|
-
runtimeStatusText(runtimeStatus(), runtimeRunStatus()),
|
|
144
|
+
runtimeStatusText(runtimeStatus(), runtimeRunStatus(), runtimeUnavailableReason(), session.workspace),
|
|
137
145
|
].join(' ');
|
|
138
146
|
});
|
|
147
|
+
const runtimeHint = createMemo(() => {
|
|
148
|
+
version();
|
|
149
|
+
if (props.runtime?.url) return null;
|
|
150
|
+
return runtimeUnavailableAgentMessage({ error: runtimeUnavailableReason() ?? 'runtime introuvable' });
|
|
151
|
+
});
|
|
139
152
|
const matchContext = createMemo(() => {
|
|
140
153
|
if (input() === dismissedSlashInput()) return null;
|
|
141
154
|
return completionContext(input(), session);
|
|
@@ -263,10 +276,54 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
263
276
|
return [...logs(), ...tagged].slice(-200);
|
|
264
277
|
});
|
|
265
278
|
|
|
279
|
+
// Index-aligned with each workspace's runtimeConversation array — not just
|
|
280
|
+
// a length count. The server sometimes finalizes a streaming placeholder
|
|
281
|
+
// (queued during a tool-calling turn, never closed out because that turn's
|
|
282
|
+
// reply had no final text) by overwriting the *same* conversation entry's
|
|
283
|
+
// content on a later turn, without the array growing. A length-based diff
|
|
284
|
+
// would miss that update entirely and leave the stale placeholder text
|
|
285
|
+
// shown forever; tracking local entry references per index picks it up.
|
|
286
|
+
const runtimeConversationRefsByWorkspace = new Map<string, any[]>();
|
|
287
|
+
function mergeRuntimeConversation(state: any) {
|
|
288
|
+
const workspace = (session as any).workspace || '__global__';
|
|
289
|
+
const runtimeConversation = Array.isArray(state?.conversation) ? state.conversation : [];
|
|
290
|
+
if (runtimeConversation.length === 0) return;
|
|
291
|
+
const target = conversationMessages(session);
|
|
292
|
+
const backed = runtimeConversationRefsByWorkspace.get(workspace) ?? [];
|
|
293
|
+
runtimeConversationRefsByWorkspace.set(workspace, backed);
|
|
294
|
+
for (let i = 0; i < runtimeConversation.length; i += 1) {
|
|
295
|
+
const message = runtimeConversation[i];
|
|
296
|
+
const content = String(message.content ?? '');
|
|
297
|
+
const role = message.role === 'assistant' ? 'donna' : message.role;
|
|
298
|
+
if (i < backed.length) {
|
|
299
|
+
const entry = backed[i];
|
|
300
|
+
if (entry.content !== content) entry.content = content;
|
|
301
|
+
if (entry.role !== role) entry.role = role;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (role === 'user') {
|
|
305
|
+
// The user's own message was already pushed optimistically (see
|
|
306
|
+
// useAgent.ts) and marked _pending — confirm that entry instead of
|
|
307
|
+
// adding a second copy. No matching pending entry (e.g. history
|
|
308
|
+
// restored after a fresh reconnect) falls through to a normal push.
|
|
309
|
+
const pendingIndex = target.findIndex((entry: any) => entry._pending && entry.role === 'user' && entry.content === content);
|
|
310
|
+
if (pendingIndex !== -1) {
|
|
311
|
+
delete target[pendingIndex]._pending;
|
|
312
|
+
backed.push(target[pendingIndex]);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const entry = { role, content };
|
|
317
|
+
target.push(entry);
|
|
318
|
+
backed.push(entry);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
266
322
|
function syncRuntimeState() {
|
|
267
323
|
void fetchRuntimeState({ url: props.runtime.url, workspace: (session as any).workspace ?? null })
|
|
268
324
|
.then((state) => {
|
|
269
325
|
setRuntimeState(state);
|
|
326
|
+
mergeRuntimeConversation(state);
|
|
270
327
|
setRuntimeStatus('connected');
|
|
271
328
|
refresh();
|
|
272
329
|
})
|
|
@@ -310,6 +367,22 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
310
367
|
syncRuntimeState();
|
|
311
368
|
void subscribeRuntimeEvents();
|
|
312
369
|
}
|
|
370
|
+
|
|
371
|
+
let currentRuntimeWorkspace = (session as any).workspace ?? null;
|
|
372
|
+
function resyncRuntimeWorkspaceIfChanged() {
|
|
373
|
+
const workspace = (session as any).workspace ?? null;
|
|
374
|
+
if (workspace === currentRuntimeWorkspace) return;
|
|
375
|
+
currentRuntimeWorkspace = workspace;
|
|
376
|
+
if (!props.runtime?.url) return;
|
|
377
|
+
if (runtimeReconnectTimer) {
|
|
378
|
+
clearTimeout(runtimeReconnectTimer);
|
|
379
|
+
runtimeReconnectTimer = null;
|
|
380
|
+
}
|
|
381
|
+
runtimeStreamAbort?.abort();
|
|
382
|
+
syncRuntimeState();
|
|
383
|
+
void subscribeRuntimeEvents();
|
|
384
|
+
}
|
|
385
|
+
|
|
313
386
|
onCleanup(() => {
|
|
314
387
|
runtimeStreamStopped = true;
|
|
315
388
|
runtimeStreamAbort?.abort();
|
|
@@ -408,6 +481,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
408
481
|
lastActivityLines.clear();
|
|
409
482
|
lastActivityDetails.clear();
|
|
410
483
|
const result = await agent.submit(line);
|
|
484
|
+
resyncRuntimeWorkspaceIfChanged();
|
|
411
485
|
if ((result as any)?.setMode === 'chat') {
|
|
412
486
|
setChatMode(true);
|
|
413
487
|
(session as any).chatMode = true;
|
|
@@ -523,6 +597,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
523
597
|
setInput: updateInput,
|
|
524
598
|
title,
|
|
525
599
|
statusLine,
|
|
600
|
+
runtimeHint,
|
|
526
601
|
chatMode,
|
|
527
602
|
showWelcome,
|
|
528
603
|
activeEditor,
|