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.
Files changed (176) hide show
  1. package/README.md +44 -31
  2. package/dist/cli.js +24 -0
  3. package/docs/.coverage-gaps.json +154 -24
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +12 -2
  6. package/docs/features/chat.md +40 -5
  7. package/docs/features/cost-usage.md +1 -1
  8. package/docs/features/documents.md +5 -2
  9. package/docs/features/inbox-notifications.md +10 -2
  10. package/docs/features/keyboard-navigation.md +12 -3
  11. package/docs/features/provider-runtimes.md +16 -2
  12. package/docs/features/settings.md +2 -2
  13. package/docs/features/shared-components.md +7 -3
  14. package/docs/features/tables.md +3 -1
  15. package/docs/features/tool-permissions.md +6 -2
  16. package/docs/features/workflows.md +6 -2
  17. package/docs/getting-started.md +1 -1
  18. package/docs/index.md +1 -1
  19. package/docs/journeys/developer.md +25 -2
  20. package/docs/journeys/personal-use.md +12 -5
  21. package/docs/journeys/power-user.md +45 -14
  22. package/docs/journeys/work-use.md +17 -8
  23. package/docs/manifest.json +15 -15
  24. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +2 -2
  25. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  26. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  27. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  28. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  29. package/next.config.mjs +1 -0
  30. package/package.json +3 -3
  31. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  32. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  33. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  34. package/src/app/api/chat/export/route.ts +52 -0
  35. package/src/app/api/chat/files/search/route.ts +50 -0
  36. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  37. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  38. package/src/app/api/environment/skills/route.ts +13 -0
  39. package/src/app/api/schedules/[id]/execute/route.ts +2 -2
  40. package/src/app/api/settings/chat/pins/route.ts +94 -0
  41. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  42. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  43. package/src/app/api/settings/environment/route.ts +26 -0
  44. package/src/app/api/tasks/[id]/execute/route.ts +52 -12
  45. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  46. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  47. package/src/app/documents/page.tsx +4 -1
  48. package/src/app/settings/page.tsx +2 -0
  49. package/src/components/book/content-blocks.tsx +1 -1
  50. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  51. package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
  52. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  53. package/src/components/chat/capability-banner.tsx +68 -0
  54. package/src/components/chat/chat-command-popover.tsx +668 -47
  55. package/src/components/chat/chat-input.tsx +103 -8
  56. package/src/components/chat/chat-message.tsx +12 -3
  57. package/src/components/chat/chat-session-provider.tsx +73 -3
  58. package/src/components/chat/chat-shell.tsx +62 -3
  59. package/src/components/chat/command-tab-bar.tsx +68 -0
  60. package/src/components/chat/conversation-template-picker.tsx +421 -0
  61. package/src/components/chat/help-dialog.tsx +39 -0
  62. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  63. package/src/components/chat/skill-row.tsx +147 -0
  64. package/src/components/documents/document-browser.tsx +37 -19
  65. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  66. package/src/components/notifications/permission-response-actions.tsx +155 -1
  67. package/src/components/playbook/playbook-detail-view.tsx +1 -1
  68. package/src/components/settings/environment-section.tsx +102 -0
  69. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  70. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  71. package/src/components/shared/command-palette.tsx +262 -2
  72. package/src/components/shared/filter-hint.tsx +70 -0
  73. package/src/components/shared/filter-input.tsx +59 -0
  74. package/src/components/shared/saved-searches-manager.tsx +199 -0
  75. package/src/components/tasks/task-bento-grid.tsx +12 -2
  76. package/src/components/tasks/task-card.tsx +3 -0
  77. package/src/components/tasks/task-chip-bar.tsx +30 -1
  78. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  79. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  80. package/src/hooks/use-active-skills.ts +110 -0
  81. package/src/hooks/use-chat-autocomplete.ts +120 -7
  82. package/src/hooks/use-enriched-skills.ts +19 -0
  83. package/src/hooks/use-pinned-entries.ts +104 -0
  84. package/src/hooks/use-recent-user-messages.ts +19 -0
  85. package/src/hooks/use-saved-searches.ts +142 -0
  86. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  87. package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
  88. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  89. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  90. package/src/lib/agents/claude-agent.ts +105 -46
  91. package/src/lib/agents/handoff/bus.ts +2 -2
  92. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  93. package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
  94. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
  95. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
  96. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  97. package/src/lib/agents/profiles/registry.ts +97 -22
  98. package/src/lib/agents/profiles/types.ts +7 -1
  99. package/src/lib/agents/router.ts +3 -6
  100. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  101. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  102. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  103. package/src/lib/agents/runtime/catalog.ts +121 -0
  104. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  105. package/src/lib/agents/runtime/execution-target.ts +456 -0
  106. package/src/lib/agents/runtime/index.ts +4 -0
  107. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  108. package/src/lib/agents/runtime/openai-codex.ts +35 -0
  109. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  110. package/src/lib/agents/task-dispatch.ts +220 -0
  111. package/src/lib/agents/tool-permissions.ts +16 -1
  112. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  113. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  114. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  115. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  116. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  117. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  118. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  119. package/src/lib/chat/__tests__/types.test.ts +28 -0
  120. package/src/lib/chat/active-skills.ts +31 -0
  121. package/src/lib/chat/clean-filter-input.ts +30 -0
  122. package/src/lib/chat/codex-engine.ts +30 -7
  123. package/src/lib/chat/command-tabs.ts +61 -0
  124. package/src/lib/chat/context-builder.ts +141 -1
  125. package/src/lib/chat/dismissals.ts +73 -0
  126. package/src/lib/chat/engine.ts +109 -15
  127. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  128. package/src/lib/chat/files/expand-mention.ts +76 -0
  129. package/src/lib/chat/files/search.ts +99 -0
  130. package/src/lib/chat/skill-composition.ts +210 -0
  131. package/src/lib/chat/skill-conflict.ts +105 -0
  132. package/src/lib/chat/stagent-tools.ts +6 -19
  133. package/src/lib/chat/stream-telemetry.ts +9 -4
  134. package/src/lib/chat/system-prompt.ts +22 -0
  135. package/src/lib/chat/tool-catalog.ts +33 -3
  136. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  137. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  138. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  139. package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
  140. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
  141. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  142. package/src/lib/chat/tools/helpers.ts +2 -0
  143. package/src/lib/chat/tools/profile-tools.ts +120 -23
  144. package/src/lib/chat/tools/skill-tools.ts +183 -0
  145. package/src/lib/chat/tools/task-tools.ts +6 -2
  146. package/src/lib/chat/tools/workflow-tools.ts +61 -20
  147. package/src/lib/chat/types.ts +15 -0
  148. package/src/lib/constants/settings.ts +2 -0
  149. package/src/lib/data/clear.ts +2 -6
  150. package/src/lib/db/bootstrap.ts +17 -0
  151. package/src/lib/db/schema.ts +26 -0
  152. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  153. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  154. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  155. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  156. package/src/lib/environment/data.ts +9 -0
  157. package/src/lib/environment/list-skills.ts +176 -0
  158. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  159. package/src/lib/environment/parsers/skill.ts +26 -5
  160. package/src/lib/environment/profile-generator.ts +56 -2
  161. package/src/lib/environment/skill-enrichment.ts +106 -0
  162. package/src/lib/environment/skill-recommendations.ts +66 -0
  163. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  164. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  165. package/src/lib/filters/parse.ts +86 -0
  166. package/src/lib/instance/__tests__/detect.test.ts +1 -1
  167. package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
  168. package/src/lib/instance/fingerprint.ts +8 -10
  169. package/src/lib/instance/upgrade-poller.ts +53 -1
  170. package/src/lib/schedules/scheduler.ts +4 -4
  171. package/src/lib/utils/stagent-paths.ts +4 -0
  172. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  173. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  174. package/src/lib/workflows/blueprints/types.ts +6 -0
  175. package/src/lib/workflows/engine.ts +5 -3
  176. 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
- <span className="text-[10px] text-muted-foreground/50 mt-0.5 ml-1">
152
- {modelLabel}
153
- </span>
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(async (): Promise<string | null> => {
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
- }, [setActiveConversation]);
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
- {!activeId && messages.length === 0 ? (
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
+ }