@swarmclawai/swarmclaw 1.5.68 → 1.5.70

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 (30) hide show
  1. package/README.md +20 -0
  2. package/package.json +4 -3
  3. package/src/app/api/providers/[id]/route.test.ts +20 -0
  4. package/src/app/api/runs/[id]/events/route.ts +10 -4
  5. package/src/app/api/runs/[id]/route.ts +6 -2
  6. package/src/app/api/runs/route.test.ts +84 -0
  7. package/src/app/api/runs/route.ts +41 -2
  8. package/src/components/agents/inspector-panel.tsx +2 -1
  9. package/src/components/chat/chat-area.tsx +57 -12
  10. package/src/components/chat/chat-header.tsx +20 -1
  11. package/src/components/layout/dashboard-shell.tsx +15 -1
  12. package/src/components/layout/sidebar-rail.tsx +4 -4
  13. package/src/components/schedules/schedule-console.tsx +148 -30
  14. package/src/features/providers/queries.ts +0 -1
  15. package/src/lib/app/view-constants.test.ts +21 -0
  16. package/src/lib/app/view-constants.ts +25 -0
  17. package/src/lib/chat/new-session.test.ts +114 -0
  18. package/src/lib/chat/new-session.ts +146 -0
  19. package/src/lib/providers/openai.test.ts +54 -0
  20. package/src/lib/providers/openai.ts +11 -7
  21. package/src/lib/server/daemon/controller.test.ts +78 -0
  22. package/src/lib/server/daemon/controller.ts +50 -7
  23. package/src/lib/server/protocols/protocol-agent-turn.test.ts +164 -0
  24. package/src/lib/server/protocols/protocol-agent-turn.ts +119 -16
  25. package/src/lib/server/provider-endpoint.ts +0 -3
  26. package/src/lib/server/runs/unified-run-records.ts +91 -0
  27. package/src/lib/server/schedules/schedule-normalization.ts +4 -1
  28. package/src/lib/server/schedules/schedule-service.test.ts +73 -0
  29. package/src/lib/server/schedules/schedule-service.ts +10 -3
  30. package/src/lib/server/test-utils/run-with-temp-data-dir.ts +3 -1
package/README.md CHANGED
@@ -399,6 +399,26 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.5.70 Highlights
403
+
404
+ Fast-follow release for [#56](https://github.com/swarmclawai/swarmclaw/pull/56) by [@latentwill](https://github.com/latentwill). Thanks latentwill!
405
+
406
+ Also includes fixes for [#57](https://github.com/swarmclawai/swarmclaw/issues/57) and [#58](https://github.com/swarmclawai/swarmclaw/issues/58) reported by [@zantak](https://github.com/zantak). Thanks zantak!
407
+
408
+ - **Builtin provider saves work again.** Saving a builtin provider no longer sends the strict-schema rejected `type` field, and the provider update route is now covered by the runtime test script.
409
+ - **Knowledge sources appear on direct visits.** Panel-backed routes such as Knowledge now auto-open their source/sidebar panel on desktop route changes, while mobile keeps the drawer closed by default.
410
+ - **Reasoning content stays out of the reply body.** OpenAI-compatible `reasoning_content` and `reasoning` stream deltas now flow into the existing collapsed Thinking panel instead of being appended before the visible answer.
411
+ - **macOS install guidance remains explicit.** Ad-hoc signed macOS desktop builds still document the quarantine workaround until Developer ID signing and notarization are available. Thanks [@yagudaev](https://github.com/yagudaev) for confirming the current workaround on Apple Silicon.
412
+
413
+ ### v1.5.69 Highlights
414
+
415
+ Fast-follow release for [#55](https://github.com/swarmclawai/swarmclaw/pull/55) by [@borislavnnikolov](https://github.com/borislavnnikolov). Thanks Borislav!
416
+
417
+ - **Structured runs are easier to find.** Schedule-backed protocol runs now appear in the schedule console and unified `/api/runs` endpoints, including detail and event fallbacks for structured run records.
418
+ - **Agent sessions get a cleaner fresh-chat flow.** Agent chat headers now expose a New chat action for sessions with history or saved CLI/runtime handles, first prompts derive compact session titles, and agent session lists sort newest-first.
419
+ - **Structured session execution is sturdier.** CLI providers can execute structured turns through their direct provider runtime, blank structured responses now surface the real logged error where possible, successful structured turns clear their watchdog timers promptly, schedule timing changes recompute `nextRunAt`, and in-process daemon status/control paths are covered.
420
+ - **Package contents are safer.** The npm package allowlist now explicitly excludes local env files under `src/` even when a maintainer has private ignored config in their working tree.
421
+
402
422
  ### v1.5.68 Highlights
403
423
 
404
424
  Launch-readiness release for turning SwarmClaw's own next launch into a reusable workflow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.68",
3
+ "version": "1.5.70",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -44,6 +44,7 @@
44
44
  "bin/",
45
45
  "skills/",
46
46
  "src/",
47
+ "!src/**/.env*",
47
48
  "public/",
48
49
  "Dockerfile.sandbox-browser",
49
50
  "scripts/easy-setup.mjs",
@@ -85,8 +86,8 @@
85
86
  "cli": "node ./bin/swarmclaw.js",
86
87
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
87
88
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
88
- "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
89
- "test:runtime": "tsx --test src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/tts/route.test.ts",
89
+ "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
90
+ "test:runtime": "tsx --test src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/app/view-constants.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
90
91
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
91
92
  "test:e2e": "tsx .workbench/browser-e2e/run.ts",
92
93
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -47,3 +47,23 @@ test('provider route upserts builtin override records for enablement changes', (
47
47
  assert.equal(output.responsePayload.type, 'builtin')
48
48
  assert.equal(output.responsePayload.isEnabled, false)
49
49
  })
50
+
51
+ test('provider route rejects unknown fields per ProviderUpdateSchema.strict()', () => {
52
+ const output = runWithTempDataDir<{ status: number }>(`
53
+ const routeMod = await import('./src/app/api/providers/[id]/route')
54
+ const route = routeMod.default || routeMod
55
+
56
+ const response = await route.PUT(
57
+ new Request('http://local/api/providers/openai', {
58
+ method: 'PUT',
59
+ headers: { 'content-type': 'application/json' },
60
+ body: JSON.stringify({ type: 'builtin', isEnabled: true }),
61
+ }),
62
+ { params: Promise.resolve({ id: 'openai' }) },
63
+ )
64
+
65
+ console.log(JSON.stringify({ status: response.status }))
66
+ `, { prefix: 'swarmclaw-provider-route-strict-test-' })
67
+
68
+ assert.equal(output.status, 400)
69
+ })
@@ -1,5 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { getRunById, listRunEvents } from '@/lib/server/runtime/session-run-manager'
3
+ import { listProtocolRunEventsForRun, loadProtocolRunById } from '@/lib/server/protocols/protocol-queries'
4
+ import { protocolEventToRunEventRecord } from '@/lib/server/runs/unified-run-records'
3
5
 
4
6
  export const dynamic = 'force-dynamic'
5
7
 
@@ -12,11 +14,15 @@ function parseLimit(value: string | null): number | undefined {
12
14
  export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
13
15
  const { id } = await params
14
16
  const run = getRunById(id)
15
- if (!run) {
16
- return NextResponse.json({ error: 'Run not found' }, { status: 404 })
17
+ if (run) {
18
+ const url = new URL(req.url)
19
+ const limit = parseLimit(url.searchParams.get('limit'))
20
+ return NextResponse.json(listRunEvents(id, limit))
17
21
  }
18
-
22
+ const protocolRun = loadProtocolRunById(id)
23
+ if (!protocolRun) return NextResponse.json({ error: 'Run not found' }, { status: 404 })
19
24
  const url = new URL(req.url)
20
25
  const limit = parseLimit(url.searchParams.get('limit'))
21
- return NextResponse.json(listRunEvents(id, limit))
26
+ const events = listProtocolRunEventsForRun(id, limit || 200).map((event) => protocolEventToRunEventRecord(protocolRun, event))
27
+ return NextResponse.json(events)
22
28
  }
@@ -1,9 +1,13 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { getRunById } from '@/lib/server/runtime/session-run-manager'
3
+ import { loadProtocolRunById } from '@/lib/server/protocols/protocol-queries'
4
+ import { protocolRunToSessionRunRecord } from '@/lib/server/runs/unified-run-records'
3
5
 
4
6
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
5
7
  const { id } = await params
6
8
  const run = getRunById(id)
7
- if (!run) return NextResponse.json({ error: 'Run not found' }, { status: 404 })
8
- return NextResponse.json(run)
9
+ if (run) return NextResponse.json(run)
10
+ const protocolRun = loadProtocolRunById(id)
11
+ if (protocolRun) return NextResponse.json(protocolRunToSessionRunRecord(protocolRun))
12
+ return NextResponse.json({ error: 'Run not found' }, { status: 404 })
9
13
  }
@@ -0,0 +1,84 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
5
+
6
+ test('runs routes include structured schedule protocol runs', () => {
7
+ const output = runWithTempDataDir<{
8
+ listCount: number
9
+ firstRunId: string | null
10
+ firstRunSource: string | null
11
+ firstRunStatus: string | null
12
+ detailId: string | null
13
+ detailSource: string | null
14
+ eventsCount: number
15
+ eventSummary: string | null
16
+ }>(`
17
+ const storageMod = await import('./src/lib/server/storage')
18
+ const protocolsMod = await import('./src/lib/server/protocols/protocol-service')
19
+ const listRouteMod = await import('./src/app/api/runs/route')
20
+ const detailRouteMod = await import('./src/app/api/runs/[id]/route')
21
+ const eventsRouteMod = await import('./src/app/api/runs/[id]/events/route')
22
+ const storage = storageMod.default || storageMod
23
+ const protocols = protocolsMod.default || protocolsMod
24
+ const listRoute = listRouteMod.default || listRouteMod
25
+ const detailRoute = detailRouteMod.default || detailRouteMod
26
+ const eventsRoute = eventsRouteMod.default || eventsRouteMod
27
+
28
+ storage.upsertStoredItem('agents', 'agentA', {
29
+ id: 'agentA',
30
+ name: 'Agent A',
31
+ provider: 'ollama',
32
+ model: 'test-model',
33
+ systemPrompt: 'test',
34
+ createdAt: 1,
35
+ updatedAt: 1,
36
+ })
37
+
38
+ const run = protocols.createProtocolRun({
39
+ title: 'Scheduled structured run',
40
+ participantAgentIds: ['agentA'],
41
+ facilitatorAgentId: 'agentA',
42
+ autoStart: false,
43
+ scheduleId: 'sched-1',
44
+ sourceRef: { kind: 'schedule', id: 'sched-1', label: 'Morning schedule' },
45
+ config: {
46
+ goal: 'Summarize the morning inbox.',
47
+ },
48
+ })
49
+
50
+ const listResponse = await listRoute.GET(new Request('http://local/api/runs?limit=10'))
51
+ const listPayload = await listResponse.json()
52
+
53
+ const detailResponse = await detailRoute.GET(
54
+ new Request('http://local/api/runs/' + run.id),
55
+ { params: Promise.resolve({ id: run.id }) },
56
+ )
57
+ const detailPayload = await detailResponse.json()
58
+
59
+ const eventsResponse = await eventsRoute.GET(
60
+ new Request('http://local/api/runs/' + run.id + '/events?limit=10'),
61
+ { params: Promise.resolve({ id: run.id }) },
62
+ )
63
+ const eventsPayload = await eventsResponse.json()
64
+
65
+ console.log(JSON.stringify({
66
+ listCount: Array.isArray(listPayload) ? listPayload.length : -1,
67
+ firstRunId: Array.isArray(listPayload) && listPayload[0] ? listPayload[0].id : null,
68
+ firstRunSource: Array.isArray(listPayload) && listPayload[0] ? listPayload[0].source : null,
69
+ firstRunStatus: Array.isArray(listPayload) && listPayload[0] ? listPayload[0].status : null,
70
+ detailId: detailPayload?.id || null,
71
+ detailSource: detailPayload?.source || null,
72
+ eventsCount: Array.isArray(eventsPayload) ? eventsPayload.length : -1,
73
+ eventSummary: Array.isArray(eventsPayload) && eventsPayload[0] ? eventsPayload[0].summary || null : null,
74
+ }))
75
+ `, { prefix: 'swarmclaw-runs-route-' })
76
+
77
+ assert.equal(output.listCount, 1)
78
+ assert.equal(output.firstRunId, output.detailId)
79
+ assert.equal(output.firstRunSource, 'structured schedule')
80
+ assert.equal(output.firstRunStatus, 'queued')
81
+ assert.equal(output.detailSource, 'structured schedule')
82
+ assert.equal(output.eventsCount >= 1, true)
83
+ assert.equal(typeof output.eventSummary, 'string')
84
+ })
@@ -1,9 +1,28 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { listRuns } from '@/lib/server/runtime/session-run-manager'
3
- import type { SessionRunStatus } from '@/types'
3
+ import { listProtocolRuns } from '@/lib/server/protocols/protocol-queries'
4
+ import { protocolRunToSessionRunRecord } from '@/lib/server/runs/unified-run-records'
5
+ import type { ProtocolRunStatus, SessionRunStatus } from '@/types'
4
6
 
5
7
  export const dynamic = 'force-dynamic'
6
8
 
9
+ function protocolStatusesForRunStatus(status?: SessionRunStatus): ProtocolRunStatus[] {
10
+ switch (status) {
11
+ case 'queued':
12
+ return ['draft']
13
+ case 'running':
14
+ return ['running', 'waiting', 'paused']
15
+ case 'completed':
16
+ return ['completed']
17
+ case 'failed':
18
+ return ['failed']
19
+ case 'cancelled':
20
+ return ['cancelled', 'archived']
21
+ default:
22
+ return []
23
+ }
24
+ }
25
+
7
26
  export async function GET(req: Request) {
8
27
  const { searchParams } = new URL(req.url)
9
28
  const sessionId = searchParams.get('sessionId') || undefined
@@ -11,6 +30,26 @@ export async function GET(req: Request) {
11
30
  const limitRaw = searchParams.get('limit')
12
31
  const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined
13
32
 
14
- const runs = listRuns({ sessionId, status, limit })
33
+ const sessionRuns = listRuns({ sessionId, status, limit })
34
+ const fetchLimit = limit || 200
35
+ const scopedProtocolRuns = status
36
+ ? protocolStatusesForRunStatus(status).flatMap((protocolStatus) => listProtocolRuns({
37
+ includeSystemOwned: true,
38
+ sessionId,
39
+ status: protocolStatus,
40
+ limit: fetchLimit,
41
+ }))
42
+ : listProtocolRuns({
43
+ includeSystemOwned: true,
44
+ sessionId,
45
+ limit: fetchLimit,
46
+ })
47
+ const protocolRuns = Array.from(new Map(scopedProtocolRuns.map((run) => [run.id, run])).values())
48
+ .filter((run) => run.status !== 'archived')
49
+ .map(protocolRunToSessionRunRecord)
50
+ .filter((run) => !status || run.status === status)
51
+ const runs = [...sessionRuns, ...protocolRuns]
52
+ .sort((left, right) => (right.queuedAt || 0) - (left.queuedAt || 0))
53
+ .slice(0, fetchLimit)
15
54
  return NextResponse.json(runs)
16
55
  }
@@ -6,6 +6,7 @@ import type { Agent, MemoryEntry, Session } from '@/types'
6
6
  import { useAppStore } from '@/stores/use-app-store'
7
7
  import { useChatStore } from '@/stores/use-chat-store'
8
8
  import { api } from '@/lib/app/api-client'
9
+ import { sortSessionsNewestFirst } from '@/lib/chat/new-session'
9
10
  import { AgentAvatar } from './agent-avatar'
10
11
  import { AgentFilesEditor } from './agent-files-editor'
11
12
  import { OpenClawSkillsPanel } from './openclaw-skills-panel'
@@ -907,7 +908,7 @@ function SessionsSection({ agent }: { agent: Agent }) {
907
908
  const setInspectorOpen = useAppStore((s) => s.setInspectorOpen)
908
909
 
909
910
  const agentSessions = useMemo(() => {
910
- return Object.values(sessions).filter((s) => s.agentId === agent.id)
911
+ return sortSessionsNewestFirst(Object.values(sessions).filter((s) => s.agentId === agent.id))
911
912
  }, [sessions, agent.id])
912
913
 
913
914
  if (agentSessions.length === 0) return null
@@ -31,6 +31,7 @@ import { api } from '@/lib/app/api-client'
31
31
  import { messagesDiffer } from '@/lib/chat/chat-streaming-state'
32
32
  import { createAssistantRenderId } from '@/lib/chat/assistant-render-id'
33
33
  import { getSessionLastMessage } from '@/lib/chat/session-summary'
34
+ import { buildNewAgentSessionPayload, summarizeFirstMessageAsTitle } from '@/lib/chat/new-session'
34
35
  import { getEnabledCapabilityIds, getEnabledToolIds } from '@/lib/capability-selection'
35
36
 
36
37
  const DIRECT_PROMPT_SUGGESTIONS = [
@@ -57,6 +58,8 @@ export function ChatArea() {
57
58
  const setCurrentAgent = useAppStore((s) => s.setCurrentAgent)
58
59
  const removeSessionFromStore = useAppStore((s) => s.removeSession)
59
60
  const refreshSession = useAppStore((s) => s.refreshSession)
61
+ const updateSessionInStore = useAppStore((s) => s.updateSessionInStore)
62
+ const setActiveSessionIdOverride = useAppStore((s) => s.setActiveSessionIdOverride)
60
63
  const appSettings = useAppStore((s) => s.appSettings)
61
64
  const messages = useChatStore((s) => s.messages)
62
65
  const messageStartIndex = useChatStore((s) => s.messageStartIndex)
@@ -172,6 +175,7 @@ export function ChatArea() {
172
175
  const hasMultipleSources = connectorSources.size > 1 || (connectorSources.size > 0 && hasDirectMessages)
173
176
  const [isDragging, setIsDragging] = useState(false)
174
177
  const dragCounter = useRef(0)
178
+ const freshSessionIdRef = useRef<string | null>(null)
175
179
  const setPendingImage = useChatStore((s) => s.setPendingImage)
176
180
 
177
181
  useEffect(() => {
@@ -180,6 +184,13 @@ export function ChatArea() {
180
184
  const requestedSessionId = sessionId
181
185
  const chatState = useChatStore.getState()
182
186
  const preserveLocalStream = chatState.streaming && chatState.streamingSessionId === requestedSessionId
187
+ if (freshSessionIdRef.current === requestedSessionId) {
188
+ freshSessionIdRef.current = null
189
+ setMessages([], { startIndex: 0, totalMessages: 0 })
190
+ useChatStore.setState({ hasMoreMessages: false })
191
+ setMessagesLoading(false)
192
+ return () => { cancelled = true }
193
+ }
183
194
  // Clear stale messages immediately so the skeleton loader shows instead of
184
195
  // the previous chat's messages flashing briefly during the fetch.
185
196
  if (!preserveLocalStream) setMessages([], { startIndex: 0, totalMessages: 0 })
@@ -431,7 +442,7 @@ export function ChatArea() {
431
442
  setDevServer(null)
432
443
  }, [sessionId, setDevServer])
433
444
 
434
- const handleClear = useCallback(async () => {
445
+ const handleClear = useCallback(async (mode: 'clear' | 'new-session' = 'clear') => {
435
446
  setConfirmClear(false)
436
447
  if (!sessionId) return
437
448
  const targetSessionId = sessionId
@@ -448,7 +459,11 @@ export function ChatArea() {
448
459
  await refreshSession(targetSessionId)
449
460
  const { undoToken, cleared } = result
450
461
  if (!undoToken) return
451
- const clearedLabel = cleared === 1 ? '1 message cleared' : `${cleared.toLocaleString()} messages cleared`
462
+ const clearedLabel = mode === 'new-session'
463
+ ? 'Started a fresh chat session.'
464
+ : cleared === 1
465
+ ? '1 message cleared'
466
+ : `${cleared.toLocaleString()} messages cleared`
452
467
  toast(clearedLabel, {
453
468
  duration: 10_000,
454
469
  action: {
@@ -487,6 +502,34 @@ export function ChatArea() {
487
502
  setConfirmClear(true)
488
503
  }, [])
489
504
 
505
+ const handleStartNewSession = useCallback(async () => {
506
+ if (!session) return
507
+ try {
508
+ const nextSession = await api<typeof session>('POST', '/chats', {
509
+ ...buildNewAgentSessionPayload(session),
510
+ name: currentAgent?.name || session.name,
511
+ })
512
+ freshSessionIdRef.current = nextSession.id
513
+ updateSessionInStore(nextSession)
514
+ setActiveSessionIdOverride(nextSession.id)
515
+ toast.success('Started a new chat session.')
516
+ } catch (err) {
517
+ toast.error(`Could not start a new chat session: ${errorMessage(err)}`)
518
+ }
519
+ }, [currentAgent?.name, session, setActiveSessionIdOverride, updateSessionInStore])
520
+
521
+ const handleSend = useCallback(async (text: string) => {
522
+ if (!sessionId) return
523
+ if (session && messages.length === 0) {
524
+ const nextTitle = summarizeFirstMessageAsTitle(text, currentAgent?.name || session.name)
525
+ if (nextTitle && nextTitle !== session.name) {
526
+ updateSessionInStore({ ...session, name: nextTitle })
527
+ void api('PUT', `/chats/${sessionId}`, { name: nextTitle }).catch(() => {})
528
+ }
529
+ }
530
+ await sendMessage(text, { sessionId })
531
+ }, [currentAgent?.name, messages.length, sendMessage, session, sessionId, updateSessionInStore])
532
+
490
533
  const handleDelete = useCallback(async () => {
491
534
  setConfirmDelete(false)
492
535
  if (!sessionId) return
@@ -496,8 +539,8 @@ export function ChatArea() {
496
539
  }, [removeSessionFromStore, sessionId, setCurrentAgent])
497
540
 
498
541
  const handlePrompt = useCallback((text: string) => {
499
- sendMessage(text)
500
- }, [sendMessage])
542
+ void handleSend(text)
543
+ }, [handleSend])
501
544
 
502
545
  const handleDragOver = useCallback((e: React.DragEvent) => {
503
546
  e.preventDefault()
@@ -568,6 +611,7 @@ export function ChatArea() {
568
611
  messageCount={messages.length}
569
612
  onCompactComplete={handleCompactComplete}
570
613
  onClearRequest={handleClearRequest}
614
+ onStartNewSession={handleStartNewSession}
571
615
  />
572
616
  )}
573
617
  {!isDesktop && (
@@ -589,6 +633,7 @@ export function ChatArea() {
589
633
  messageCount={messages.length}
590
634
  onCompactComplete={handleCompactComplete}
591
635
  onClearRequest={handleClearRequest}
636
+ onStartNewSession={handleStartNewSession}
592
637
  />
593
638
  )}
594
639
  <DevServerBar status={devServerStatus} onStop={handleStopDevServer} />
@@ -691,13 +736,13 @@ export function ChatArea() {
691
736
  onClose={() => setDebugOpen(false)}
692
737
  />
693
738
 
694
- <ChatInput
695
- streaming={streamingForThisSession}
696
- busy={streamingForThisSession || session.active === true}
697
- onSend={sendMessage}
698
- onStop={stopStreaming}
699
- extensionChatActions={extensionChatActions}
700
- />
739
+ <ChatInput
740
+ streaming={streamingForThisSession}
741
+ busy={streamingForThisSession || session.active === true}
742
+ onSend={handleSend}
743
+ onStop={stopStreaming}
744
+ extensionChatActions={extensionChatActions}
745
+ />
701
746
 
702
747
  <Dropdown open={menuOpen} onClose={() => setMenuOpen(false)}>
703
748
  <DropdownItem onClick={() => {
@@ -720,7 +765,7 @@ export function ChatArea() {
720
765
  message="Clear every message in this chat. Long-term memory, skills, and facts are preserved. You'll have 10 seconds to undo."
721
766
  confirmLabel="Clear"
722
767
  danger
723
- onConfirm={handleClear}
768
+ onConfirm={() => { void handleClear('clear') }}
724
769
  onCancel={() => setConfirmClear(false)}
725
770
  />
726
771
  <ConfirmDialog
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useEffect, useState, useMemo, useRef, type ReactNode } from 'react'
4
+ import { Plus } from 'lucide-react'
4
5
  import type { Session } from '@/types'
5
6
  import { useAppStore } from '@/stores/use-app-store'
6
7
  import { useChatStore } from '@/stores/use-chat-store'
@@ -17,6 +18,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip
17
18
  import { copyTextToClipboard } from '@/lib/clipboard'
18
19
  import { useNavigate } from '@/lib/app/navigation'
19
20
  import { getEnabledToolIds } from '@/lib/capability-selection'
21
+ import { getNewSessionButtonTitle, hasResettableSessionRuntime } from '@/lib/chat/new-session'
20
22
  import { ContextMeterBadge } from './context-meter-badge'
21
23
 
22
24
  function Tip({ label, children, side = 'bottom' }: { label: string; children: ReactNode; side?: 'top' | 'bottom' | 'left' | 'right' }) {
@@ -84,9 +86,10 @@ interface Props {
84
86
  messageCount?: number
85
87
  onCompactComplete?: () => void
86
88
  onClearRequest?: () => void
89
+ onStartNewSession?: () => void
87
90
  }
88
91
 
89
- export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported, connectorSources, connectorFilter, onConnectorFilterChange, hasMultipleSources, messageCount = 0, onCompactComplete, onClearRequest }: Props) {
92
+ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported, connectorSources, connectorFilter, onConnectorFilterChange, hasMultipleSources, messageCount = 0, onCompactComplete, onClearRequest, onStartNewSession }: Props) {
90
93
  const now = useNow()
91
94
  const agentStatus = useChatStore((s) => s.agentStatus)
92
95
  const agents = useAppStore((s) => s.agents)
@@ -114,6 +117,8 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
114
117
  const renameInputRef = useRef<HTMLInputElement>(null)
115
118
  const renameContainerRef = useRef<HTMLSpanElement>(null)
116
119
  const liveStatus = agentStatus || null
120
+ const canStartNewSession = !streaming && !!onStartNewSession && (messageCount > 0 || hasResettableSessionRuntime(session))
121
+ const newSessionTitle = getNewSessionButtonTitle(session)
117
122
  const connectorPresenceMeta = useMemo(() => {
118
123
  if (!connector) return null
119
124
  const lastAt = connectorPresence?.lastMessageAt
@@ -431,6 +436,20 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
431
436
  onClearRequest={onClearRequest}
432
437
  />
433
438
  )}
439
+ {canStartNewSession && (
440
+ <Tip label={newSessionTitle}>
441
+ <button
442
+ type="button"
443
+ onClick={onStartNewSession}
444
+ className="inline-flex items-center gap-1.5 rounded-[9px] border border-white/[0.06] bg-white/[0.03] px-2.5 py-1 text-[10px] font-600 text-text-3/70 transition-colors shrink-0 cursor-pointer hover:border-white/[0.15] hover:bg-white/[0.05] hover:text-text-2"
445
+ aria-label="Start a new chat session"
446
+ title={newSessionTitle}
447
+ >
448
+ <Plus className="h-3 w-3" aria-hidden="true" strokeWidth={2.2} />
449
+ <span>New chat</span>
450
+ </button>
451
+ </Tip>
452
+ )}
434
453
  </div>
435
454
  {liveStatus?.status && (
436
455
  <div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-1.5">
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState, useCallback } from 'react'
3
+ import { useEffect, useRef, useState, useCallback } from 'react'
4
4
  import { useRouter, usePathname } from 'next/navigation'
5
5
  import { initAudioContext } from '@/lib/tts'
6
6
  import { clearStoredAccessKey } from '@/lib/app/api-client'
@@ -13,6 +13,7 @@ import { useSwipe } from '@/hooks/use-swipe'
13
13
  import { useWs } from '@/hooks/use-ws'
14
14
  import { api } from '@/lib/app/api-client'
15
15
  import { pathToView, useNavigate } from '@/lib/app/navigation'
16
+ import { shouldAutoOpenPanelSidebar } from '@/lib/app/view-constants'
16
17
 
17
18
  import { FullScreenLoader } from '@/components/ui/full-screen-loader'
18
19
  import { SidebarRail } from '@/components/layout/sidebar-rail'
@@ -45,6 +46,7 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
45
46
 
46
47
  const [bootTimedOut, setBootTimedOut] = useState(false)
47
48
  const [profileSheetOpen, setProfileSheetOpen] = useState(false)
49
+ const lastAutoOpenedPanelPathRef = useRef<string | null>(null)
48
50
  const sidebarOpen = useAppStore((s) => s.sidebarOpen)
49
51
  const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
50
52
  const appSettings = useAppStore((s) => s.appSettings)
@@ -165,6 +167,18 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
165
167
  }
166
168
  }, [pathname, isViewEnabled, router, isAuthPage])
167
169
 
170
+ useEffect(() => {
171
+ if (isAuthPage) return
172
+ const currentView = pathToView(pathname)
173
+ if (!shouldAutoOpenPanelSidebar(currentView, isDesktop)) {
174
+ lastAutoOpenedPanelPathRef.current = null
175
+ return
176
+ }
177
+ if (lastAutoOpenedPanelPathRef.current === pathname) return
178
+ lastAutoOpenedPanelPathRef.current = pathname
179
+ setSidebarOpen(true)
180
+ }, [isAuthPage, isDesktop, pathname, setSidebarOpen])
181
+
168
182
  // Extension sidebar items
169
183
  const refreshExtensionState = useCallback(() => {
170
184
  void loadExtensions()
@@ -9,7 +9,7 @@ import { DaemonIndicator } from '@/components/layout/daemon-indicator'
9
9
  import { NotificationCenter } from '@/components/shared/notification-center'
10
10
  import { NavItem, RailTooltip } from '@/components/layout/nav-item'
11
11
  import { useWs } from '@/hooks/use-ws'
12
- import { FULL_WIDTH_VIEWS } from '@/lib/app/view-constants'
12
+ import { FULL_WIDTH_VIEWS, isPanelSidebarView } from '@/lib/app/view-constants'
13
13
  import { pathToView, useNavigate } from '@/lib/app/navigation'
14
14
  import { safeStorageGet, safeStorageSet } from '@/lib/app/safe-storage'
15
15
  import type { AppView } from '@/types'
@@ -80,9 +80,9 @@ export function SidebarRail({
80
80
  setSidebarOpen(false)
81
81
  return
82
82
  }
83
- if (FULL_WIDTH_VIEWS.has(view)) {
84
- setSidebarOpen(false)
85
- } else if (activeView === view && sidebarOpen) {
83
+ if (isPanelSidebarView(view)) {
84
+ setSidebarOpen(!(activeView === view && sidebarOpen))
85
+ } else if (FULL_WIDTH_VIEWS.has(view)) {
86
86
  setSidebarOpen(false)
87
87
  } else {
88
88
  setSidebarOpen(true)