stagent 0.10.0 → 0.11.1
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 +44 -31
- package/dist/cli.js +24 -0
- package/docs/.coverage-gaps.json +154 -24
- package/docs/.last-generated +1 -1
- package/docs/features/agent-intelligence.md +12 -2
- package/docs/features/chat.md +40 -5
- package/docs/features/cost-usage.md +1 -1
- package/docs/features/documents.md +5 -2
- package/docs/features/inbox-notifications.md +10 -2
- package/docs/features/keyboard-navigation.md +12 -3
- package/docs/features/provider-runtimes.md +16 -2
- package/docs/features/settings.md +2 -2
- package/docs/features/shared-components.md +7 -3
- package/docs/features/tables.md +3 -1
- package/docs/features/tool-permissions.md +6 -2
- package/docs/features/workflows.md +6 -2
- package/docs/getting-started.md +1 -1
- package/docs/index.md +1 -1
- package/docs/journeys/developer.md +25 -2
- package/docs/journeys/personal-use.md +12 -5
- package/docs/journeys/power-user.md +45 -14
- package/docs/journeys/work-use.md +17 -8
- package/docs/manifest.json +15 -15
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +2 -2
- package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
- package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
- package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
- package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
- package/next.config.mjs +1 -0
- package/package.json +3 -3
- package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
- package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
- package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
- package/src/app/api/chat/export/route.ts +52 -0
- package/src/app/api/chat/files/search/route.ts +50 -0
- package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
- package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
- package/src/app/api/environment/skills/route.ts +13 -0
- package/src/app/api/schedules/[id]/execute/route.ts +2 -2
- package/src/app/api/settings/chat/pins/route.ts +94 -0
- package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
- package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
- package/src/app/api/settings/environment/route.ts +26 -0
- package/src/app/api/tasks/[id]/execute/route.ts +52 -12
- package/src/app/api/tasks/[id]/respond/route.ts +31 -15
- package/src/app/api/tasks/[id]/resume/route.ts +24 -3
- package/src/app/documents/page.tsx +4 -1
- package/src/app/settings/page.tsx +2 -0
- package/src/components/book/content-blocks.tsx +1 -1
- package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
- package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
- package/src/components/chat/capability-banner.tsx +68 -0
- package/src/components/chat/chat-command-popover.tsx +668 -47
- package/src/components/chat/chat-input.tsx +103 -8
- package/src/components/chat/chat-message.tsx +12 -3
- package/src/components/chat/chat-session-provider.tsx +73 -3
- package/src/components/chat/chat-shell.tsx +62 -3
- package/src/components/chat/command-tab-bar.tsx +68 -0
- package/src/components/chat/conversation-template-picker.tsx +421 -0
- package/src/components/chat/help-dialog.tsx +39 -0
- package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
- package/src/components/chat/skill-row.tsx +147 -0
- package/src/components/documents/document-browser.tsx +37 -19
- package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
- package/src/components/notifications/permission-response-actions.tsx +155 -1
- package/src/components/playbook/playbook-detail-view.tsx +1 -1
- package/src/components/settings/environment-section.tsx +102 -0
- package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
- package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
- package/src/components/shared/command-palette.tsx +262 -2
- package/src/components/shared/filter-hint.tsx +70 -0
- package/src/components/shared/filter-input.tsx +59 -0
- package/src/components/shared/saved-searches-manager.tsx +199 -0
- package/src/components/tasks/task-bento-grid.tsx +12 -2
- package/src/components/tasks/task-card.tsx +3 -0
- package/src/components/tasks/task-chip-bar.tsx +30 -1
- package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
- package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
- package/src/hooks/use-active-skills.ts +110 -0
- package/src/hooks/use-chat-autocomplete.ts +120 -7
- package/src/hooks/use-enriched-skills.ts +19 -0
- package/src/hooks/use-pinned-entries.ts +104 -0
- package/src/hooks/use-recent-user-messages.ts +19 -0
- package/src/hooks/use-saved-searches.ts +142 -0
- package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
- package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
- package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
- package/src/lib/agents/claude-agent.ts +105 -46
- package/src/lib/agents/handoff/bus.ts +2 -2
- package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
- package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
- package/src/lib/agents/profiles/registry.ts +97 -22
- package/src/lib/agents/profiles/types.ts +7 -1
- package/src/lib/agents/router.ts +3 -6
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
- package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
- package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
- package/src/lib/agents/runtime/catalog.ts +121 -0
- package/src/lib/agents/runtime/claude-sdk.ts +32 -0
- package/src/lib/agents/runtime/execution-target.ts +456 -0
- package/src/lib/agents/runtime/index.ts +4 -0
- package/src/lib/agents/runtime/launch-failure.ts +101 -0
- package/src/lib/agents/runtime/openai-codex.ts +35 -0
- package/src/lib/agents/runtime/openai-direct.ts +8 -0
- package/src/lib/agents/task-dispatch.ts +220 -0
- package/src/lib/agents/tool-permissions.ts +16 -1
- package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
- package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
- package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
- package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
- package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
- package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
- package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
- package/src/lib/chat/__tests__/types.test.ts +28 -0
- package/src/lib/chat/active-skills.ts +31 -0
- package/src/lib/chat/clean-filter-input.ts +30 -0
- package/src/lib/chat/codex-engine.ts +30 -7
- package/src/lib/chat/command-tabs.ts +61 -0
- package/src/lib/chat/context-builder.ts +141 -1
- package/src/lib/chat/dismissals.ts +73 -0
- package/src/lib/chat/engine.ts +109 -15
- package/src/lib/chat/files/__tests__/search.test.ts +135 -0
- package/src/lib/chat/files/expand-mention.ts +76 -0
- package/src/lib/chat/files/search.ts +99 -0
- package/src/lib/chat/skill-composition.ts +210 -0
- package/src/lib/chat/skill-conflict.ts +105 -0
- package/src/lib/chat/stagent-tools.ts +6 -19
- package/src/lib/chat/stream-telemetry.ts +9 -4
- package/src/lib/chat/system-prompt.ts +22 -0
- package/src/lib/chat/tool-catalog.ts +33 -3
- package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
- package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
- package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
- package/src/lib/chat/tools/blueprint-tools.ts +190 -0
- package/src/lib/chat/tools/helpers.ts +2 -0
- package/src/lib/chat/tools/profile-tools.ts +120 -23
- package/src/lib/chat/tools/skill-tools.ts +183 -0
- package/src/lib/chat/tools/task-tools.ts +6 -2
- package/src/lib/chat/tools/workflow-tools.ts +61 -20
- package/src/lib/chat/types.ts +15 -0
- package/src/lib/constants/settings.ts +2 -0
- package/src/lib/data/clear.ts +2 -6
- package/src/lib/db/bootstrap.ts +17 -0
- package/src/lib/db/schema.ts +26 -0
- package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
- package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
- package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
- package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
- package/src/lib/environment/data.ts +9 -0
- package/src/lib/environment/list-skills.ts +176 -0
- package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
- package/src/lib/environment/parsers/skill.ts +26 -5
- package/src/lib/environment/profile-generator.ts +56 -2
- package/src/lib/environment/skill-enrichment.ts +106 -0
- package/src/lib/environment/skill-recommendations.ts +66 -0
- package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
- package/src/lib/filters/__tests__/parse.test.ts +135 -0
- package/src/lib/filters/parse.ts +86 -0
- package/src/lib/instance/__tests__/detect.test.ts +1 -1
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
- package/src/lib/instance/fingerprint.ts +8 -10
- package/src/lib/instance/upgrade-poller.ts +53 -1
- package/src/lib/schedules/scheduler.ts +4 -4
- package/src/lib/utils/stagent-paths.ts +4 -0
- package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
- package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
- package/src/lib/workflows/blueprints/types.ts +6 -0
- package/src/lib/workflows/engine.ts +5 -3
- package/src/test/setup.ts +10 -0
|
@@ -6,11 +6,14 @@ import { Square } from "lucide-react";
|
|
|
6
6
|
import { cn } from "@/lib/utils";
|
|
7
7
|
import { ChatModelSelector } from "./chat-model-selector";
|
|
8
8
|
import { ChatCommandPopover } from "./chat-command-popover";
|
|
9
|
+
import { CapabilityBanner } from "./capability-banner";
|
|
9
10
|
import { useChatAutocomplete, type MentionReference } from "@/hooks/use-chat-autocomplete";
|
|
10
11
|
import { getToolCatalog } from "@/lib/chat/tool-catalog";
|
|
11
12
|
import { useProjectSkills } from "@/hooks/use-project-skills";
|
|
12
13
|
import { toggleTheme } from "@/lib/theme";
|
|
13
14
|
import type { ChatModelOption } from "@/lib/chat/types";
|
|
15
|
+
import { getRuntimeForModel } from "@/lib/chat/types";
|
|
16
|
+
import { resolveAgentRuntime } from "@/lib/agents/runtime/catalog";
|
|
14
17
|
|
|
15
18
|
interface ChatInputProps {
|
|
16
19
|
onSend: (content: string, mentions?: MentionReference[]) => void;
|
|
@@ -22,6 +25,13 @@ interface ChatInputProps {
|
|
|
22
25
|
onModelChange?: (modelId: string) => void;
|
|
23
26
|
availableModels?: ChatModelOption[];
|
|
24
27
|
projectId?: string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Conversation id. When set, the input hydrates an initial draft from
|
|
30
|
+
* `sessionStorage["chat:prefill:<id>"]` on mount (one-shot, removed after
|
|
31
|
+
* read). Used by the conversation-template-picker to seed the composer
|
|
32
|
+
* without a schema change.
|
|
33
|
+
*/
|
|
34
|
+
conversationId?: string | null;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
export function ChatInput({
|
|
@@ -34,12 +44,45 @@ export function ChatInput({
|
|
|
34
44
|
onModelChange,
|
|
35
45
|
availableModels,
|
|
36
46
|
projectId,
|
|
47
|
+
conversationId,
|
|
37
48
|
}: ChatInputProps) {
|
|
38
49
|
const [value, setValue] = useState("");
|
|
50
|
+
|
|
51
|
+
// One-shot hydration from sessionStorage when this input mounts for a
|
|
52
|
+
// conversation that was just created from a template. Two keys:
|
|
53
|
+
// 1. `chat:prefill:<id>` — per-id slot (survives across hero→docked mount)
|
|
54
|
+
// 2. `chat:prefill:pending` — id-less slot written by the template picker
|
|
55
|
+
// BEFORE it awaits createConversation (race-order safe: by the time
|
|
56
|
+
// createConversation resolves, this effect has already fired).
|
|
57
|
+
// Key is removed after read so page reload doesn't re-inject.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!conversationId) return;
|
|
60
|
+
try {
|
|
61
|
+
const idKey = `chat:prefill:${conversationId}`;
|
|
62
|
+
const byId = window.sessionStorage.getItem(idKey);
|
|
63
|
+
const pending = window.sessionStorage.getItem("chat:prefill:pending");
|
|
64
|
+
const seed = byId ?? pending;
|
|
65
|
+
if (seed && seed.length > 0) {
|
|
66
|
+
setValue(seed);
|
|
67
|
+
}
|
|
68
|
+
// Always clear both slots after consumption so reload / nav doesn't
|
|
69
|
+
// re-inject. Clearing even when seed is null is safe — the keys either
|
|
70
|
+
// don't exist (noop) or belong to a stale prior flow.
|
|
71
|
+
window.sessionStorage.removeItem(idKey);
|
|
72
|
+
window.sessionStorage.removeItem("chat:prefill:pending");
|
|
73
|
+
} catch {
|
|
74
|
+
// sessionStorage access can throw in some browser modes — silently
|
|
75
|
+
// fall back to an empty composer.
|
|
76
|
+
}
|
|
77
|
+
}, [conversationId]);
|
|
39
78
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
40
|
-
const autocomplete = useChatAutocomplete();
|
|
79
|
+
const autocomplete = useChatAutocomplete({ projectId });
|
|
41
80
|
const { skills: projectSkills } = useProjectSkills(projectId);
|
|
42
81
|
|
|
82
|
+
const effectiveRuntime = resolveAgentRuntime(
|
|
83
|
+
modelId ? getRuntimeForModel(modelId) : null
|
|
84
|
+
);
|
|
85
|
+
|
|
43
86
|
// Sync textarea ref with autocomplete hook
|
|
44
87
|
useEffect(() => {
|
|
45
88
|
autocomplete.setTextareaRef(textareaRef.current);
|
|
@@ -60,6 +103,35 @@ export function ChatInput({
|
|
|
60
103
|
}
|
|
61
104
|
}, [value, isStreaming, onSend, autocomplete.mentions]);
|
|
62
105
|
|
|
106
|
+
const executeSessionCommand = useCallback((name: string) => {
|
|
107
|
+
switch (name) {
|
|
108
|
+
case "toggle_theme":
|
|
109
|
+
toggleTheme();
|
|
110
|
+
return;
|
|
111
|
+
case "mark_all_read":
|
|
112
|
+
fetch("/api/notifications/mark-all-read", { method: "PATCH" });
|
|
113
|
+
return;
|
|
114
|
+
case "clear":
|
|
115
|
+
window.dispatchEvent(new CustomEvent("stagent.chat.clear"));
|
|
116
|
+
return;
|
|
117
|
+
case "compact":
|
|
118
|
+
window.dispatchEvent(new CustomEvent("stagent.chat.compact"));
|
|
119
|
+
return;
|
|
120
|
+
case "export":
|
|
121
|
+
window.dispatchEvent(new CustomEvent("stagent.chat.export"));
|
|
122
|
+
return;
|
|
123
|
+
case "help":
|
|
124
|
+
window.dispatchEvent(new CustomEvent("stagent.chat.help"));
|
|
125
|
+
return;
|
|
126
|
+
case "settings":
|
|
127
|
+
window.location.href = "/settings";
|
|
128
|
+
return;
|
|
129
|
+
case "new-from-template":
|
|
130
|
+
window.dispatchEvent(new CustomEvent("stagent.chat.openTemplatePicker"));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
63
135
|
const handleKeyDown = useCallback(
|
|
64
136
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
65
137
|
// Let autocomplete handle keys first when popover is open
|
|
@@ -67,6 +139,24 @@ export function ChatInput({
|
|
|
67
139
|
return;
|
|
68
140
|
}
|
|
69
141
|
|
|
142
|
+
const cmd = e.metaKey || e.ctrlKey;
|
|
143
|
+
if (cmd && (e.key === "l" || e.key === "L")) {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
if (!isStreaming) executeSessionCommand("clear");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (cmd && e.key === "/") {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
textareaRef.current?.focus();
|
|
151
|
+
setValue((v) => (v.startsWith("/") ? v : "/" + v));
|
|
152
|
+
requestAnimationFrame(() => {
|
|
153
|
+
if (textareaRef.current) {
|
|
154
|
+
autocomplete.handleChange(textareaRef.current.value, textareaRef.current);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
70
160
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
71
161
|
e.preventDefault();
|
|
72
162
|
handleSend();
|
|
@@ -75,7 +165,7 @@ export function ChatInput({
|
|
|
75
165
|
textareaRef.current?.blur();
|
|
76
166
|
}
|
|
77
167
|
},
|
|
78
|
-
[handleSend, autocomplete.handleKeyDown]
|
|
168
|
+
[handleSend, autocomplete.handleKeyDown, autocomplete.handleChange, executeSessionCommand, isStreaming]
|
|
79
169
|
);
|
|
80
170
|
|
|
81
171
|
// Auto-resize textarea
|
|
@@ -112,12 +202,8 @@ export function ChatInput({
|
|
|
112
202
|
const entry = getToolCatalog({ includeBrowser: true }).find((t) => t.name === item.id);
|
|
113
203
|
if (entry?.behavior === "execute_immediately") {
|
|
114
204
|
autocomplete.close();
|
|
115
|
-
if (entry.name === "toggle_theme") {
|
|
116
|
-
toggleTheme();
|
|
117
|
-
} else if (entry.name === "mark_all_read") {
|
|
118
|
-
fetch("/api/notifications/mark-all-read", { method: "PATCH" });
|
|
119
|
-
}
|
|
120
205
|
setValue("");
|
|
206
|
+
executeSessionCommand(entry.name);
|
|
121
207
|
return;
|
|
122
208
|
}
|
|
123
209
|
}
|
|
@@ -133,7 +219,7 @@ export function ChatInput({
|
|
|
133
219
|
});
|
|
134
220
|
}
|
|
135
221
|
},
|
|
136
|
-
[autocomplete, handleInput]
|
|
222
|
+
[autocomplete, handleInput, executeSessionCommand]
|
|
137
223
|
);
|
|
138
224
|
|
|
139
225
|
// Show preview text in placeholder when hovering a suggestion
|
|
@@ -201,6 +287,12 @@ export function ChatInput({
|
|
|
201
287
|
</div>
|
|
202
288
|
</div>
|
|
203
289
|
|
|
290
|
+
{!isStreaming && (
|
|
291
|
+
<div className="mx-auto max-w-3xl">
|
|
292
|
+
<CapabilityBanner runtimeId={effectiveRuntime} />
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
|
|
204
296
|
{/* Autocomplete popover — rendered via portal */}
|
|
205
297
|
<ChatCommandPopover
|
|
206
298
|
open={autocomplete.state.open}
|
|
@@ -210,8 +302,11 @@ export function ChatInput({
|
|
|
210
302
|
entityResults={autocomplete.entityResults}
|
|
211
303
|
entityLoading={autocomplete.entityLoading}
|
|
212
304
|
projectProfiles={projectSkills.length > 0 ? projectSkills : undefined}
|
|
305
|
+
activeTab={autocomplete.activeTab}
|
|
306
|
+
onTabChange={autocomplete.setActiveTab}
|
|
213
307
|
onSelect={handlePopoverSelect}
|
|
214
308
|
onClose={autocomplete.close}
|
|
309
|
+
conversationId={conversationId}
|
|
215
310
|
/>
|
|
216
311
|
</div>
|
|
217
312
|
);
|
|
@@ -80,12 +80,14 @@ export function ChatMessage({ message, isStreaming, conversationId, onStatusChan
|
|
|
80
80
|
let quickAccess: QuickAccessItem[] = [];
|
|
81
81
|
let attachments: ScreenshotAttachment[] = [];
|
|
82
82
|
let modelLabel: string | null = null;
|
|
83
|
+
let fallbackReason: string | null = null;
|
|
83
84
|
if (!isUser && message.metadata) {
|
|
84
85
|
try {
|
|
85
86
|
const meta = JSON.parse(message.metadata);
|
|
86
87
|
if (Array.isArray(meta.quickAccess)) quickAccess = meta.quickAccess;
|
|
87
88
|
if (Array.isArray(meta.attachments)) attachments = meta.attachments;
|
|
88
89
|
if (meta.modelId) modelLabel = resolveModelLabel(meta.modelId);
|
|
90
|
+
if (meta.fallbackReason) fallbackReason = meta.fallbackReason;
|
|
89
91
|
} catch {
|
|
90
92
|
// Invalid metadata
|
|
91
93
|
}
|
|
@@ -148,9 +150,16 @@ export function ChatMessage({ message, isStreaming, conversationId, onStatusChan
|
|
|
148
150
|
</div>
|
|
149
151
|
{/* Model label for completed assistant messages */}
|
|
150
152
|
{!isUser && !isStreaming && modelLabel && (
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
<div className="mt-0.5 ml-1 space-y-0.5">
|
|
154
|
+
<span className="block text-[10px] text-muted-foreground/50">
|
|
155
|
+
{modelLabel}
|
|
156
|
+
</span>
|
|
157
|
+
{fallbackReason && (
|
|
158
|
+
<span className="block text-[10px] text-amber-700/80 dark:text-amber-300/80">
|
|
159
|
+
{fallbackReason}
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
154
163
|
)}
|
|
155
164
|
</div>
|
|
156
165
|
);
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { useRouter } from "next/navigation";
|
|
39
39
|
import { toast } from "sonner";
|
|
40
40
|
import type { ConversationRow, ChatMessageRow } from "@/lib/db/schema";
|
|
41
|
+
import { HelpDialog } from "./help-dialog";
|
|
41
42
|
import {
|
|
42
43
|
DEFAULT_CHAT_MODEL,
|
|
43
44
|
CHAT_MODELS,
|
|
@@ -73,7 +74,7 @@ interface ChatSessionValue {
|
|
|
73
74
|
setActiveConversation: (id: string | null, opts?: { skipLoad?: boolean }) => void;
|
|
74
75
|
sendMessage: (content: string, mentions?: MentionReference[]) => Promise<void>;
|
|
75
76
|
stopStreaming: () => void;
|
|
76
|
-
createConversation: () => Promise<string | null>;
|
|
77
|
+
createConversation: (opts?: { title?: string }) => Promise<string | null>;
|
|
77
78
|
deleteConversation: (id: string) => Promise<void>;
|
|
78
79
|
renameConversation: (id: string, title: string) => Promise<void>;
|
|
79
80
|
setMessageStatus: (
|
|
@@ -111,11 +112,15 @@ export function ChatSessionProvider({ children }: { children: ReactNode }) {
|
|
|
111
112
|
useState<ChatModelOption[]>(CHAT_MODELS);
|
|
112
113
|
const [hydrated, setHydrated] = useState(false);
|
|
113
114
|
|
|
115
|
+
const [helpDialogOpen, setHelpDialogOpen] = useState(false);
|
|
116
|
+
|
|
114
117
|
// Refs for values read from async callbacks that mustn't see stale state.
|
|
115
118
|
const activeIdRef = useRef<string | null>(null);
|
|
116
119
|
activeIdRef.current = activeId;
|
|
117
120
|
const modelIdRef = useRef<string>(modelId);
|
|
118
121
|
modelIdRef.current = modelId;
|
|
122
|
+
const messagesByConversationRef = useRef<Record<string, ChatMessageRow[]>>({});
|
|
123
|
+
messagesByConversationRef.current = messagesByConversation;
|
|
119
124
|
|
|
120
125
|
// ── One-time model + available-models fetch ──────────────────────────
|
|
121
126
|
// Runs once per page load (provider lives in root layout, not /chat page).
|
|
@@ -259,7 +264,8 @@ export function ChatSessionProvider({ children }: { children: ReactNode }) {
|
|
|
259
264
|
);
|
|
260
265
|
|
|
261
266
|
// ── Conversation CRUD ────────────────────────────────────────────────
|
|
262
|
-
const createConversation = useCallback(
|
|
267
|
+
const createConversation = useCallback(
|
|
268
|
+
async (opts?: { title?: string }): Promise<string | null> => {
|
|
263
269
|
try {
|
|
264
270
|
const res = await fetch("/api/chat/conversations", {
|
|
265
271
|
method: "POST",
|
|
@@ -267,6 +273,7 @@ export function ChatSessionProvider({ children }: { children: ReactNode }) {
|
|
|
267
273
|
body: JSON.stringify({
|
|
268
274
|
runtimeId: getRuntimeForModel(modelIdRef.current),
|
|
269
275
|
modelId: modelIdRef.current,
|
|
276
|
+
...(opts?.title ? { title: opts.title } : {}),
|
|
270
277
|
}),
|
|
271
278
|
});
|
|
272
279
|
if (!res.ok) return null;
|
|
@@ -285,7 +292,69 @@ export function ChatSessionProvider({ children }: { children: ReactNode }) {
|
|
|
285
292
|
} catch {
|
|
286
293
|
return null;
|
|
287
294
|
}
|
|
288
|
-
},
|
|
295
|
+
},
|
|
296
|
+
[setActiveConversation]
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// ── Environment rescan on conversation activation ────────────────────
|
|
300
|
+
// Fire-and-forget; endpoint self-guards with shouldRescan() (5min TTL).
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
if (!activeId) return;
|
|
303
|
+
fetch("/api/environment/rescan-if-stale", { method: "POST" }).catch(() => {});
|
|
304
|
+
}, [activeId]);
|
|
305
|
+
|
|
306
|
+
// ── Chat command event listeners ─────────────────────────────────────
|
|
307
|
+
// Handles CustomEvents dispatched by chat-input.tsx (⌘L, slash commands).
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
const handleClear = () => {
|
|
310
|
+
void createConversation();
|
|
311
|
+
};
|
|
312
|
+
const handleCompact = () => {
|
|
313
|
+
toast.info("Compact is not wired yet — coming soon.");
|
|
314
|
+
};
|
|
315
|
+
const handleExport = async () => {
|
|
316
|
+
const activeConversationId = activeIdRef.current;
|
|
317
|
+
const msgs = activeConversationId
|
|
318
|
+
? messagesByConversationRef.current[activeConversationId]
|
|
319
|
+
: undefined;
|
|
320
|
+
if (!msgs || msgs.length === 0) {
|
|
321
|
+
toast.error("Nothing to export — this conversation is empty.");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const title = `Chat — ${new Date().toISOString().slice(0, 10)}`;
|
|
325
|
+
const markdown = msgs
|
|
326
|
+
.map((m) => `### ${m.role === "user" ? "You" : "Assistant"}\n\n${m.content}`)
|
|
327
|
+
.join("\n\n---\n\n");
|
|
328
|
+
try {
|
|
329
|
+
const res = await fetch("/api/chat/export", {
|
|
330
|
+
method: "POST",
|
|
331
|
+
headers: { "Content-Type": "application/json" },
|
|
332
|
+
body: JSON.stringify({
|
|
333
|
+
title,
|
|
334
|
+
markdown,
|
|
335
|
+
conversationId: activeConversationId,
|
|
336
|
+
}),
|
|
337
|
+
});
|
|
338
|
+
if (!res.ok) throw new Error(`Export failed: ${res.status}`);
|
|
339
|
+
toast.success("Conversation exported to documents.");
|
|
340
|
+
} catch (err) {
|
|
341
|
+
toast.error(err instanceof Error ? err.message : "Export failed");
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
const handleHelp = () => setHelpDialogOpen(true);
|
|
345
|
+
|
|
346
|
+
window.addEventListener("stagent.chat.clear", handleClear);
|
|
347
|
+
window.addEventListener("stagent.chat.compact", handleCompact);
|
|
348
|
+
window.addEventListener("stagent.chat.export", handleExport);
|
|
349
|
+
window.addEventListener("stagent.chat.help", handleHelp);
|
|
350
|
+
|
|
351
|
+
return () => {
|
|
352
|
+
window.removeEventListener("stagent.chat.clear", handleClear);
|
|
353
|
+
window.removeEventListener("stagent.chat.compact", handleCompact);
|
|
354
|
+
window.removeEventListener("stagent.chat.export", handleExport);
|
|
355
|
+
window.removeEventListener("stagent.chat.help", handleHelp);
|
|
356
|
+
};
|
|
357
|
+
}, [createConversation]);
|
|
289
358
|
|
|
290
359
|
const deleteConversation = useCallback(
|
|
291
360
|
async (id: string) => {
|
|
@@ -700,6 +769,7 @@ export function ChatSessionProvider({ children }: { children: ReactNode }) {
|
|
|
700
769
|
return (
|
|
701
770
|
<ChatSessionContext.Provider value={value}>
|
|
702
771
|
{children}
|
|
772
|
+
<HelpDialog open={helpDialogOpen} onOpenChange={setHelpDialogOpen} />
|
|
703
773
|
</ChatSessionContext.Provider>
|
|
704
774
|
);
|
|
705
775
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback, useEffect, useMemo } from "react";
|
|
3
|
+
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
|
4
4
|
import type { ConversationRow } from "@/lib/db/schema";
|
|
5
5
|
import type { PromptCategory } from "@/lib/chat/types";
|
|
6
6
|
import { useChatSession } from "./chat-session-provider";
|
|
@@ -9,9 +9,10 @@ import { ChatMessageList } from "./chat-message-list";
|
|
|
9
9
|
import { ChatInput } from "./chat-input";
|
|
10
10
|
import { ChatEmptyState } from "./chat-empty-state";
|
|
11
11
|
import { ChatActivityIndicator } from "./chat-activity-indicator";
|
|
12
|
+
import { ConversationTemplatePicker } from "./conversation-template-picker";
|
|
12
13
|
import { Button } from "@/components/ui/button";
|
|
13
14
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
|
14
|
-
import { PanelRightOpen } from "lucide-react";
|
|
15
|
+
import { PanelRightOpen, Sparkles } from "lucide-react";
|
|
15
16
|
|
|
16
17
|
interface ChatShellProps {
|
|
17
18
|
initialConversations: ConversationRow[];
|
|
@@ -56,6 +57,46 @@ export function ChatShell({
|
|
|
56
57
|
// View-local state only
|
|
57
58
|
const [mobileListOpen, setMobileListOpen] = useState(false);
|
|
58
59
|
const [hoverPreview, setHoverPreview] = useState<string | null>(null);
|
|
60
|
+
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
|
61
|
+
|
|
62
|
+
// Open the template picker from any source (empty-state button, slash
|
|
63
|
+
// command, palette). Central handler keeps the open state authoritative.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
function onOpen() {
|
|
66
|
+
setTemplatePickerOpen(true);
|
|
67
|
+
}
|
|
68
|
+
window.addEventListener("stagent.chat.openTemplatePicker", onOpen);
|
|
69
|
+
return () =>
|
|
70
|
+
window.removeEventListener("stagent.chat.openTemplatePicker", onOpen);
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
// Track streaming state + activeId in refs so the unmount cleanup sees the
|
|
74
|
+
// values at unmount time, not at effect-setup time (closure-capture bug).
|
|
75
|
+
// If ChatShell unmounts while a stream is in flight (user navigated away),
|
|
76
|
+
// log a telemetry breadcrumb. The stream itself continues inside
|
|
77
|
+
// ChatSessionProvider — this log only exists so diagnostics can confirm
|
|
78
|
+
// the provider-hoisting fix is holding. See `src/lib/chat/stream-telemetry.ts`
|
|
79
|
+
// for the full reason code list.
|
|
80
|
+
const isStreamingRef = useRef(isStreaming);
|
|
81
|
+
const activeIdRef = useRef(activeId);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
isStreamingRef.current = isStreaming;
|
|
84
|
+
}, [isStreaming]);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
activeIdRef.current = activeId;
|
|
87
|
+
}, [activeId]);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
return () => {
|
|
90
|
+
if (isStreamingRef.current) {
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.info("[chat-stream] client.stream.view-remount", {
|
|
93
|
+
conversationId: activeIdRef.current,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
// Empty deps: exactly-once cleanup on unmount.
|
|
98
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
99
|
+
}, []);
|
|
59
100
|
|
|
60
101
|
// Hydrate provider once with the server-rendered conversation list.
|
|
61
102
|
// Subsequent remounts are no-ops — the provider preserves its state.
|
|
@@ -184,7 +225,7 @@ export function ChatShell({
|
|
|
184
225
|
</Sheet>
|
|
185
226
|
</div>
|
|
186
227
|
|
|
187
|
-
{
|
|
228
|
+
{messages.length === 0 ? (
|
|
188
229
|
/* Hero mode: vertically centered greeting + input + chips */
|
|
189
230
|
<div className="flex-1 flex items-center justify-center overflow-hidden">
|
|
190
231
|
<ChatEmptyState
|
|
@@ -202,7 +243,19 @@ export function ChatShell({
|
|
|
202
243
|
onModelChange={setModelId}
|
|
203
244
|
availableModels={availableModels}
|
|
204
245
|
projectId={activeConversation?.projectId}
|
|
246
|
+
conversationId={activeId}
|
|
205
247
|
/>
|
|
248
|
+
<div className="mt-3 flex justify-center">
|
|
249
|
+
<Button
|
|
250
|
+
variant="ghost"
|
|
251
|
+
size="sm"
|
|
252
|
+
className="text-xs gap-1.5"
|
|
253
|
+
onClick={() => setTemplatePickerOpen(true)}
|
|
254
|
+
>
|
|
255
|
+
<Sparkles className="h-3.5 w-3.5" />
|
|
256
|
+
Start from template
|
|
257
|
+
</Button>
|
|
258
|
+
</div>
|
|
206
259
|
</ChatEmptyState>
|
|
207
260
|
</div>
|
|
208
261
|
) : (
|
|
@@ -232,11 +285,17 @@ export function ChatShell({
|
|
|
232
285
|
onModelChange={setModelId}
|
|
233
286
|
availableModels={availableModels}
|
|
234
287
|
projectId={activeConversation?.projectId}
|
|
288
|
+
conversationId={activeId}
|
|
235
289
|
/>
|
|
236
290
|
</>
|
|
237
291
|
)}
|
|
238
292
|
</div>
|
|
239
293
|
|
|
294
|
+
<ConversationTemplatePicker
|
|
295
|
+
open={templatePickerOpen}
|
|
296
|
+
onOpenChange={setTemplatePickerOpen}
|
|
297
|
+
/>
|
|
298
|
+
|
|
240
299
|
{/* Desktop conversation list — right side */}
|
|
241
300
|
<div className="hidden lg:flex lg:w-[280px] lg:flex-col lg:border-l border-border">
|
|
242
301
|
{conversationListContent}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import { COMMAND_TABS, type CommandTabId } from "@/lib/chat/command-tabs";
|
|
6
|
+
|
|
7
|
+
interface CommandTabBarProps {
|
|
8
|
+
activeTab: CommandTabId;
|
|
9
|
+
onChange: (tab: CommandTabId) => void;
|
|
10
|
+
counts?: Partial<Record<CommandTabId, number>>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CommandTabBar({ activeTab, onChange, counts }: CommandTabBarProps) {
|
|
14
|
+
const handleKeyDown = useCallback(
|
|
15
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
16
|
+
const idx = COMMAND_TABS.findIndex((t) => t.id === activeTab);
|
|
17
|
+
if (e.key === "ArrowLeft") {
|
|
18
|
+
e.preventDefault();
|
|
19
|
+
const prev = COMMAND_TABS[(idx - 1 + COMMAND_TABS.length) % COMMAND_TABS.length];
|
|
20
|
+
onChange(prev.id);
|
|
21
|
+
} else if (e.key === "ArrowRight") {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
const next = COMMAND_TABS[(idx + 1) % COMMAND_TABS.length];
|
|
24
|
+
onChange(next.id);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
[activeTab, onChange]
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
role="tablist"
|
|
33
|
+
aria-label="Command categories"
|
|
34
|
+
onKeyDown={handleKeyDown}
|
|
35
|
+
className="flex items-center gap-1 border-b border-border px-2 pt-2"
|
|
36
|
+
>
|
|
37
|
+
{COMMAND_TABS.map((tab) => {
|
|
38
|
+
const selected = tab.id === activeTab;
|
|
39
|
+
const count = counts?.[tab.id];
|
|
40
|
+
return (
|
|
41
|
+
<button
|
|
42
|
+
key={tab.id}
|
|
43
|
+
role="tab"
|
|
44
|
+
aria-selected={selected}
|
|
45
|
+
aria-controls={`command-tabpanel-${tab.id}`}
|
|
46
|
+
id={`command-tab-${tab.id}`}
|
|
47
|
+
tabIndex={selected ? 0 : -1}
|
|
48
|
+
onClick={() => onChange(tab.id)}
|
|
49
|
+
className={cn(
|
|
50
|
+
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
|
51
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
52
|
+
selected
|
|
53
|
+
? "bg-muted text-foreground"
|
|
54
|
+
: "text-muted-foreground hover:text-foreground"
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{tab.label}
|
|
58
|
+
{typeof count === "number" && count > 0 && (
|
|
59
|
+
<span className="ml-1.5 text-[10px] text-muted-foreground/70">
|
|
60
|
+
{count}
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
</button>
|
|
64
|
+
);
|
|
65
|
+
})}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|