create-theokit 1.10.0 → 1.11.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-theokit",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "description": "Scaffold a new TheoKit project",
6
6
  "license": "Apache-2.0",
@@ -15,7 +15,7 @@
15
15
  "cross-spawn": "^7.0.6"
16
16
  },
17
17
  "publishConfig": {
18
- "provenance": true,
18
+ "provenance": false,
19
19
  "access": "public"
20
20
  },
21
21
  "engines": {
@@ -1,12 +1,22 @@
1
1
  // @vitest-environment jsdom
2
2
  import { act, renderHook } from '@testing-library/react'
3
3
  import { describe, it, expect, vi } from 'vitest'
4
+ import type { UIMessage } from 'ai'
4
5
 
5
- // Mock the agent client so the hook's transcript logic is tested deterministically (no real stream).
6
+ // Mock the agent client. Since M46 the store owns the conversation `thread`; the hook is a thin
7
+ // projection, so we drive the mock's `thread`/`status` and assert the hook maps + delegates correctly.
6
8
  const send = vi.fn()
7
9
  const resetAgent = vi.fn()
10
+ let agentThread: UIMessage[] = []
11
+ let agentStatus = 'idle'
8
12
  vi.mock('theokit/client', () => ({
9
- useAgent: () => ({ messages: [], send, status: 'idle', reset: resetAgent, error: undefined }),
13
+ useAgent: () => ({
14
+ thread: agentThread,
15
+ send,
16
+ status: agentStatus,
17
+ reset: resetAgent,
18
+ error: undefined,
19
+ }),
10
20
  }))
11
21
 
12
22
  const { useChatTranscript } = await import('./use-transcript')
@@ -17,29 +27,45 @@ function textOf(part: unknown): string {
17
27
 
18
28
  describe('useChatTranscript', () => {
19
29
  it('starts warm — the thread is just the greeting, and onlyGreeting is true', () => {
30
+ agentThread = []
31
+ agentStatus = 'idle'
20
32
  const { result } = renderHook(() => useChatTranscript())
21
33
  expect(result.current.thread).toHaveLength(1)
22
34
  expect(result.current.thread[0].id).toBe('greeting')
23
35
  expect(result.current.onlyGreeting).toBe(true)
24
36
  })
25
37
 
26
- it('sendMessage appends the user message and starts an agent turn', () => {
38
+ it('sendMessage delegates to the store (which owns appending the user turn)', () => {
39
+ agentThread = []
40
+ agentStatus = 'idle'
27
41
  const { result } = renderHook(() => useChatTranscript())
28
42
  act(() => result.current.sendMessage('hello'))
29
- const last = result.current.thread[result.current.thread.length - 1]
30
- expect(last.role).toBe('user')
31
- expect(textOf(last.parts[0])).toBe('hello')
32
43
  expect(send).toHaveBeenCalledWith({ message: 'hello' })
33
- // A user message means the greeting is no longer alone.
44
+ })
45
+
46
+ it('projects the store thread after the greeting; onlyGreeting is false once a turn exists', () => {
47
+ agentThread = [
48
+ { id: 'u-0', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
49
+ { id: 'a-0', role: 'assistant', parts: [{ type: 'text', text: 'hello there' }] },
50
+ ]
51
+ agentStatus = 'done'
52
+ const { result } = renderHook(() => useChatTranscript())
53
+ expect(result.current.thread).toHaveLength(3) // greeting + user + assistant
54
+ expect(result.current.thread[0].id).toBe('greeting')
55
+ const last = result.current.thread[2]
56
+ expect(last.role).toBe('assistant')
57
+ expect(textOf(last.parts[0])).toBe('hello there')
34
58
  expect(result.current.onlyGreeting).toBe(false)
35
59
  })
36
60
 
37
- it('reset clears the transcript back to the greeting and cancels the stream', () => {
61
+ it('reset delegates to the store reset and the transcript returns to the greeting', () => {
62
+ // After reset the store yields an empty thread; the hook must project back to just the greeting.
63
+ agentThread = []
64
+ agentStatus = 'idle'
38
65
  const { result } = renderHook(() => useChatTranscript())
39
- act(() => result.current.sendMessage('hi'))
40
66
  act(() => result.current.reset())
67
+ expect(resetAgent).toHaveBeenCalled()
41
68
  expect(result.current.thread).toHaveLength(1)
42
69
  expect(result.current.thread[0].id).toBe('greeting')
43
- expect(resetAgent).toHaveBeenCalled()
44
70
  })
45
71
  })
@@ -1,23 +1,18 @@
1
1
  'use client'
2
2
 
3
3
  import { type UIMessage } from '@theokit/ui'
4
- import { useEffect, useState } from 'react'
5
4
  import { useAgent } from 'theokit/client'
6
5
 
7
6
  import { GREETING } from '../lib/constants'
8
7
 
9
8
  /**
10
- * Owns the chat transcript the subtle part of an agent UI, so it lives in a hook, not the view. This is
11
- * the convergent pattern across AI chat frontends (Vercel ai-chatbot's `use-active-chat`, the AI SDK docs):
12
- * transcript + streaming state in a hook; the page + components stay presentational.
13
- *
14
- * Why a hook is needed: `useAgent` opens a FRESH stream per send, so its `messages` hold only the CURRENT
15
- * turn (with no stable id). We OWN the full transcript here — `history` accumulates each finished turn with
16
- * our own unique ids, and the in-flight reply is shown live until it commits: correct order, complete
17
- * history, unique keys.
9
+ * Owns the chat transcript for the view. Since M46, the framework's client store accumulates the full
10
+ * conversation `thread` (committed turns + the in-flight streaming reply, with stable ids), so this hook
11
+ * is a thin projection: prepend the warm greeting and map status to the flags the UI reads. The 88-line
12
+ * hand-rolled transcript (local history + commit-once effect + inflight-merge) is gone — the store owns it.
18
13
  */
19
14
  export interface ChatTranscript {
20
- /** The full transcript to render (committed history + the in-flight reply while streaming). */
15
+ /** The full transcript to render (greeting + committed history + the in-flight reply while streaming). */
21
16
  thread: UIMessage[]
22
17
  isStreaming: boolean
23
18
  hasError: boolean
@@ -31,58 +26,14 @@ export interface ChatTranscript {
31
26
  }
32
27
 
33
28
  export function useChatTranscript(): ChatTranscript {
34
- const [history, setHistory] = useState<UIMessage[]>([GREETING])
35
- const {
36
- messages,
37
- send,
38
- status,
39
- reset: resetAgent,
40
- error,
41
- } = useAgent<{ message: string }>('/api/agents/chat')
42
-
43
- const isStreaming = status === 'streaming'
44
- const users = history.filter((m) => m.role === 'user').length
45
- const replies = history.filter((m) => m.role === 'assistant' && m.id !== 'greeting').length
46
- // A sent prompt is still awaiting its committed reply — the current turn is "in flight".
47
- const pending = users > replies
48
-
49
- // Merge the in-flight turn's parts into ONE assistant message with our own unique id (the SDK's ids are
50
- // empty, which would collide). `suffix` distinguishes the live copy from the committed one.
51
- const inflightReply = (suffix: string): UIMessage => ({
52
- id: `a-${String(replies)}${suffix}`,
53
- role: 'assistant',
54
- parts: messages.flatMap((m) => m.parts),
55
- })
56
-
57
- // Commit the finished reply into history exactly once (the next send resets `messages`). Deps are the
58
- // stream-transition inputs; `inflightReply` is deliberately NOT a dep — it's a fresh closure each render
59
- // that would re-fire the effect, and it only reads `messages`/`replies`, which ARE deps. (If you enable
60
- // the `react-hooks/exhaustive-deps` lint rule, add an eslint-disable for this line.)
61
- useEffect(() => {
62
- if (!isStreaming && pending && messages.length > 0) {
63
- setHistory((h) => [...h, inflightReply('')])
64
- }
65
- }, [isStreaming, pending, messages, replies])
66
-
67
- // Transcript = committed history + the in-flight reply (shown until it commits — no flicker, no double).
68
- const thread = pending && messages.length > 0 ? [...history, inflightReply('-live')] : history
69
-
29
+ const { thread, send, status, reset, error } = useAgent<{ message: string }>('/api/agents/chat')
70
30
  return {
71
- thread,
72
- isStreaming,
31
+ thread: [GREETING, ...thread],
32
+ isStreaming: status === 'streaming',
73
33
  hasError: status === 'error',
74
34
  error,
75
- onlyGreeting: history.length === 1 && !isStreaming,
76
- sendMessage(text) {
77
- setHistory((h) => [
78
- ...h,
79
- { id: `u-${String(h.length)}`, role: 'user', parts: [{ type: 'text', text }] },
80
- ])
81
- send({ message: text })
82
- },
83
- reset() {
84
- setHistory([GREETING])
85
- resetAgent()
86
- },
35
+ onlyGreeting: thread.length === 0 && status !== 'streaming',
36
+ sendMessage: (text) => send({ message: text }),
37
+ reset,
87
38
  }
88
39
  }
@@ -15,7 +15,7 @@
15
15
  "typecheck": "tsc --noEmit"
16
16
  },
17
17
  "dependencies": {
18
- "theokit": "^0.36.0",
18
+ "theokit": "^0.38.0",
19
19
  "@theokit/agents": "^0.38.0",
20
20
  "@theokit/sdk": "^2.25.0",
21
21
  "@theokit/ui": "^1.0.0",
@@ -1,4 +1,4 @@
1
- import { useEffect, useState, type CSSProperties, type ReactElement } from 'react'
1
+ import { useState, type CSSProperties, type ReactElement } from 'react'
2
2
  import {
3
3
  ChatThread,
4
4
  ChatMessage,
@@ -92,50 +92,23 @@ const styles = {
92
92
 
93
93
  export function App(): ReactElement {
94
94
  const [composerValue, setComposerValue] = useState('')
95
- const [history, setHistory] = useState<UIMessage[]>([GREETING])
96
- const { messages, send, status, reset, error } = useAgent<{ message: string }>(transport)
95
+ const { thread: agentThread, send, status, reset, error } = useAgent<{ message: string }>(transport)
97
96
 
98
97
  const isStreaming = status === 'streaming'
99
98
  const hasError = status === 'error'
100
99
 
101
- const users = history.filter((m) => m.role === 'user').length
102
- const replies = history.filter((m) => m.role === 'assistant' && m.id !== 'greeting').length
103
- // A sent prompt is still awaiting its committed reply — the current turn is "in flight".
104
- const pending = users > replies
105
-
106
- // Merge the in-flight turn's parts into ONE assistant message with our own unique id (the SDK's ids are
107
- // empty, which would collide). `suffix` distinguishes the live copy from the committed one.
108
- const inflightReply = (suffix: string): UIMessage => ({
109
- id: `a-${String(replies)}${suffix}`,
110
- role: 'assistant',
111
- parts: messages.flatMap((m) => m.parts),
112
- })
113
-
114
- // Commit the finished reply into history exactly once (the next send resets `messages`).
115
- useEffect(() => {
116
- if (!isStreaming && pending && messages.length > 0) {
117
- setHistory((h) => [...h, inflightReply('')])
118
- }
119
- // eslint-disable-next-line react-hooks/exhaustive-deps
120
- }, [isStreaming, pending, messages, replies])
121
-
122
- // Transcript = committed history + the in-flight reply (shown until it commits — no flicker, no double).
123
- const thread = pending && messages.length > 0 ? [...history, inflightReply('-live')] : history
124
- const onlyGreeting = history.length === 1 && !isStreaming
100
+ // The store owns the conversation (M46) prepend the warm greeting and render.
101
+ const thread = [GREETING, ...agentThread]
102
+ const onlyGreeting = agentThread.length === 0 && !isStreaming
125
103
 
126
104
  function handleSubmit(value: string): void {
127
105
  const trimmed = value.trim()
128
106
  if (trimmed.length === 0) return
129
- setHistory((h) => [
130
- ...h,
131
- { id: `u-${String(h.length)}`, role: 'user', parts: [{ type: 'text', text: trimmed }] },
132
- ])
133
107
  send({ message: trimmed })
134
108
  setComposerValue('')
135
109
  }
136
110
 
137
111
  function newChat(): void {
138
- setHistory([GREETING])
139
112
  reset()
140
113
  setComposerValue('')
141
114
  }
@@ -1,5 +1,5 @@
1
1
  import { Box, Text, useApp, useInput } from 'ink'
2
- import { useEffect, useState, type ReactElement } from 'react'
2
+ import { type ReactElement } from 'react'
3
3
  import {
4
4
  AgentStreaming,
5
5
  AppStatusBar,
@@ -42,10 +42,9 @@ const apiKey = (): string =>
42
42
  * `WelcomeBanner` header, scrolling `<ChatThread>`, live `<AgentStreaming>`, bordered `<ChatComposer>`, and a
43
43
  * persistent `<AppStatusBar>` footer. Driven by the unified `useAgent` hook (M41).
44
44
  *
45
- * `useAgent` opens a FRESH stream per send — its `messages` hold only the CURRENT turn (and the SDK assigns
46
- * them no stable id), so we OWN the transcript: `history` accumulates every finished turn with our own unique
47
- * ids (`u-N` / `a-N`), and the in-flight reply is shown live until it commits. This keeps the order correct,
48
- * the history complete, and every id unique (ChatThread rejects duplicates).
45
+ * The full conversation comes straight from `useAgent().thread` (M46): the client store accumulates every
46
+ * finished turn plus the in-flight streaming reply, with stable ids so no hand-rolled history/commit-once
47
+ * here. Prepend the warm `GREETING` and render.
49
48
  */
50
49
  const transport = new InProcessTransport({
51
50
  run: (input) => streamAgentTurnInProcess(chatAgent, apiKey(), input),
@@ -57,31 +56,8 @@ export function App(): ReactElement {
57
56
  const elapsed = useTurnElapsed(streaming)
58
57
  const { exit } = useApp()
59
58
 
60
- const [history, setHistory] = useState<UIMessage[]>([GREETING])
61
-
62
- const users = history.filter((m) => m.role === 'user').length
63
- const replies = history.filter((m) => m.role === 'assistant' && m.id !== 'greeting').length
64
- // A sent prompt is still awaiting its committed reply — the current turn is "in flight".
65
- const pending = users > replies
66
-
67
- // Merge the in-flight turn's parts into ONE assistant message with our own unique id (the SDK's ids are
68
- // empty, which would collide in the thread). `suffix` distinguishes the live copy from the committed one.
69
- const inflightReply = (suffix: string): UIMessage => ({
70
- id: `a-${String(replies)}${suffix}`,
71
- role: 'assistant',
72
- parts: agent.messages.flatMap((m) => m.parts),
73
- })
74
-
75
- // Commit the finished reply into history exactly once (the next send resets `agent.messages`).
76
- useEffect(() => {
77
- if (!streaming && pending && agent.messages.length > 0) {
78
- setHistory((h) => [...h, inflightReply('')])
79
- }
80
- // eslint-disable-next-line react-hooks/exhaustive-deps
81
- }, [streaming, pending, agent.messages, replies])
82
-
83
- // Transcript = committed history + the in-flight reply (shown until it commits — no flicker, no double).
84
- const thread = pending && agent.messages.length > 0 ? [...history, inflightReply('-live')] : history
59
+ // The store owns the conversation (M46) — prepend the warm greeting and render.
60
+ const thread = [GREETING, ...agent.thread]
85
61
 
86
62
  // Esc cancels a running turn, or quits when idle (the Claude Code affordance).
87
63
  useInput((_input, key) => {
@@ -94,14 +70,9 @@ export function App(): ReactElement {
94
70
  const trimmed = text.trim()
95
71
  if (trimmed.length === 0) return
96
72
  if (trimmed === '/clear') {
97
- setHistory([GREETING])
98
73
  agent.reset()
99
74
  return
100
75
  }
101
- setHistory((h) => [
102
- ...h,
103
- { id: `u-${String(h.length)}`, role: 'user', parts: [{ type: 'text', text: trimmed }] },
104
- ])
105
76
  agent.send({ message: trimmed })
106
77
  }
107
78