diff --git a/2_frontend/src/features/snap/api/snap.stream.ts b/2_frontend/src/features/snap/api/snap.stream.ts
index 6c01dde..7c87afc 100644
--- a/2_frontend/src/features/snap/api/snap.stream.ts
+++ b/2_frontend/src/features/snap/api/snap.stream.ts
@@ -1,4 +1,4 @@
-import { streamLLM, type LLMUsagePayload } from "@/lib/streaming"
+import { streamLLM, type AgentStep, type LLMUsagePayload } from "@/lib/streaming"
import { apiPost } from "@/lib/api/client"
import type { SnapStreamRequest } from "../contract/types"
@@ -7,6 +7,7 @@ export interface SnapStreamHandlers {
onDone: () => void
onTitle?: (title: string) => void
onUsage?: (usage: LLMUsagePayload) => void
+ onStep?: (step: AgentStep) => void
onError?: (e: Error) => void
}
@@ -25,6 +26,7 @@ export function snapStream(
onDone: () => handlers.onDone(),
onTitle: handlers.onTitle,
onUsage: handlers.onUsage,
+ onStep: handlers.onStep,
onError: handlers.onError,
},
})
diff --git a/2_frontend/src/features/snap/components/ThinkingSteps.tsx b/2_frontend/src/features/snap/components/ThinkingSteps.tsx
new file mode 100644
index 0000000..a4e76f1
--- /dev/null
+++ b/2_frontend/src/features/snap/components/ThinkingSteps.tsx
@@ -0,0 +1,86 @@
+import { useEffect, useState } from "react"
+import { ChevronRight, Loader2, Wrench } from "lucide-react"
+import type { AgentStep } from "@/lib/streaming"
+
+/** 생각/도구 과정 토글 — ABAP_OPENCODE 의 ThinkingBox 를 그대로 옮김.
+ * 진행 중엔 펼쳐진 채 흐르고, 답변 본문이 나오기 시작하거나 끝나면 자동으로 접힘. */
+export function ThinkingSteps({
+ steps,
+ isDone,
+ answerStarted,
+}: {
+ steps: AgentStep[]
+ isDone: boolean
+ answerStarted: boolean
+}) {
+ const collapsed = isDone || answerStarted
+ const [open, setOpen] = useState(!collapsed)
+ useEffect(() => {
+ if (collapsed) setOpen(false)
+ }, [collapsed])
+ if (steps.length === 0) return null
+
+ return (
+
+
setOpen((o) => !o)}
+ className="text-muted-foreground hover:text-foreground flex items-center gap-1.5 self-start text-[12.5px] font-semibold"
+ >
+
+ 생각 과정
+ {steps.length} steps
+ {!isDone && }
+
+ {open &&
+ steps.map((st, i) => (
+
+
+ Step {i + 1}
+
+ {st.kind === "tool" ? (
+
+ {st.status === "running" || st.status === "pending" ? (
+
+ ) : (
+
+ )}
+ {st.tool || "tool"}
+ {st.title ? ` — ${st.title}` : ""}
+
+ ) : (
+
+ {st.text}
+
+ )}
+
+ ))}
+
+ )
+}
+
+/** 하단 "작성 중… · N초" — 스피너 + 경과 초. busy 가 true 인 동안만 그림. */
+export function WorkingRow({ busy }: { busy: boolean }) {
+ const [elapsed, setElapsed] = useState(0)
+ useEffect(() => {
+ if (!busy) return
+ setElapsed(0)
+ const start = Date.now()
+ const t = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000)
+ return () => clearInterval(t)
+ }, [busy])
+ if (!busy) return null
+ return (
+
+
+ 작성 중… · {elapsed}초
+
+ )
+}
diff --git a/2_frontend/src/features/snap/hooks/useSnapChat.ts b/2_frontend/src/features/snap/hooks/useSnapChat.ts
index 1b9988c..d1edab2 100644
--- a/2_frontend/src/features/snap/hooks/useSnapChat.ts
+++ b/2_frontend/src/features/snap/hooks/useSnapChat.ts
@@ -39,6 +39,7 @@ export function useSnapChat(sessionId: string) {
},
{
onToken: (d) => useSnapChatStore.getState().appendChunk(d),
+ onStep: (st) => useSnapChatStore.getState().applyStep(st),
// done 시점엔 백엔드가 이미 답변을 DB 에 저장함(streaming.py: persist→usage→done).
// detail 캐시를 무효화해 재진입 시 stale 스냅샷 대신 완성본을 받게 함.
onDone: () => {
diff --git a/2_frontend/src/features/snap/pages/NewChatPage.tsx b/2_frontend/src/features/snap/pages/NewChatPage.tsx
index e2e9368..49fab9c 100644
--- a/2_frontend/src/features/snap/pages/NewChatPage.tsx
+++ b/2_frontend/src/features/snap/pages/NewChatPage.tsx
@@ -58,6 +58,7 @@ export default function NewChatPage() {
) : (
// 세션 만드는 동안 — SessionChatPage 첫 화면과 같은 모양(내 말풍선 + 생각 중)
+ {/* eslint-disable-next-line jsx-a11y/aria-role -- Message 의 role 은 ARIA 아니고 화자 */}
diff --git a/2_frontend/src/features/snap/pages/SessionChatPage.tsx b/2_frontend/src/features/snap/pages/SessionChatPage.tsx
index a11632d..374921c 100644
--- a/2_frontend/src/features/snap/pages/SessionChatPage.tsx
+++ b/2_frontend/src/features/snap/pages/SessionChatPage.tsx
@@ -12,6 +12,7 @@ import { ChatHeader } from "../components/ChatHeader"
import { NavRail } from "../components/NavRail"
import { Message } from "../components/Message"
import { ThinkingBubble } from "../components/ThinkingBubble"
+import { ThinkingSteps, WorkingRow } from "../components/ThinkingSteps"
import { Composer } from "../components/Composer"
export default function SessionChatPage() {
@@ -164,12 +165,20 @@ export default function SessionChatPage() {
const isLiveLast =
busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen
if (isLiveLast) {
+ const steps = m.steps ?? []
return (
-
+
+
{m.content === "" ? (
- // 첫 토큰 오기 전 빈 시간 메움 — 시머 텍스트로 "생각 중" 신호
- 생각하는 중…
+ // 첫 토큰 오기 전 빈 시간 메움 — 과정도 아직 없으면 시머로 "생각 중" 신호
+ steps.length === 0 && (
+ 생각하는 중…
+ )
) : (
)}
+
)
}
@@ -196,16 +206,25 @@ export default function SessionChatPage() {
)
}
return (
-
+
+ {m.steps && m.steps.length > 0 && (
+
+ )}
+
+
)
})}
- {generatingRemotely &&
}
+ {generatingRemotely && (
+
+
+
+
+ )}
{
expect(useSnapChatStore.getState().isRevealing).toBe(false)
})
})
+
+describe("applyStep", () => {
+ it("reasoning 은 같은 id 로 이어붙고 tool 은 상태 덮어씀, assistant 꼬리에만", () => {
+ const st = useSnapChatStore.getState()
+ st.reset()
+ st.addUserMessage("q")
+ st.applyStep({ id: "r1", kind: "reasoning", text: "x" }) // user 꼬리 → 무시
+ st.startAssistantMessage()
+ st.applyStep({ id: "r1", kind: "reasoning", text: "생각" })
+ st.applyStep({ id: "r1", kind: "reasoning", text: "중" })
+ st.applyStep({ id: "t1", kind: "tool", tool: "read", status: "running" })
+ st.applyStep({ id: "t1", kind: "tool", tool: "read", status: "completed", title: "a.abap" })
+ const last = useSnapChatStore.getState().messages.at(-1)!
+ expect(last.steps).toEqual([
+ { id: "r1", kind: "reasoning", text: "생각중" },
+ { id: "t1", kind: "tool", tool: "read", status: "completed", title: "a.abap" },
+ ])
+ expect(useSnapChatStore.getState().messages[0].steps).toBeUndefined()
+ })
+})
diff --git a/2_frontend/src/features/snap/store/snapChatStore.ts b/2_frontend/src/features/snap/store/snapChatStore.ts
index 9c60d3a..56506c7 100644
--- a/2_frontend/src/features/snap/store/snapChatStore.ts
+++ b/2_frontend/src/features/snap/store/snapChatStore.ts
@@ -1,6 +1,7 @@
import { create } from "zustand"
import { randomId } from "@/lib/utils/randomId"
import type { SnapMessage, SnapRole } from "../contract/types"
+import type { AgentStep } from "@/lib/streaming"
export interface SnapChatMessage {
id: string
@@ -14,6 +15,8 @@ export interface SnapChatMessage {
totalTokens?: number
/** 이 답변 소요시간(ms). 라이브는 SSE, 과거는 DB. user 는 없음. */
elapsedMs?: number
+ /** 답변 전 과정(생각·도구). 라이브 턴에만 쌓임 — DB 엔 안 남아 재진입 땐 없음. */
+ steps?: AgentStep[]
}
// 세션 컨텍스트 하드 한도 — 백엔드 settings.llm_context_limit 와 동기(reload 시 기본값).
@@ -45,6 +48,8 @@ interface SnapChatState {
/** 폴링 복구로 도착한 완성 답변을 꼬리에 붙이고 타자기 reveal 시작. */
appendRecoveredAssistant: (m: SnapMessage) => void
appendChunk: (chunk: string) => void
+ /** step 이벤트 — 같은 id 면 reasoning 은 text 이어붙이고 tool 은 상태 덮어씀. */
+ applyStep: (step: AgentStep) => void
setStreaming: (v: boolean) => void
setRevealing: (v: boolean) => void
setController: (c: AbortController | null) => void
@@ -123,6 +128,21 @@ export const useSnapChatStore = create((set, get) => ({
return { messages: next }
})
},
+ applyStep: (step) => {
+ const last = get().messages.at(-1)
+ if (!last || last.role !== "assistant") return
+ const steps = [...(last.steps ?? [])]
+ const i = steps.findIndex((x) => x.id === step.id)
+ if (i < 0) steps.push(step)
+ else if (step.kind === "reasoning")
+ steps[i] = { ...steps[i], text: (steps[i].text ?? "") + (step.text ?? "") }
+ else steps[i] = { ...steps[i], ...step }
+ set((s) => {
+ const next = [...s.messages]
+ next[next.length - 1] = { ...last, steps }
+ return { messages: next }
+ })
+ },
setStreaming: (v) => set({ isStreaming: v }),
setRevealing: (v) => set({ isRevealing: v }),
setController: (c) => set({ currentController: c }),
diff --git a/2_frontend/src/lib/streaming/streamLLM.ts b/2_frontend/src/lib/streaming/streamLLM.ts
index d9a849a..ad1eca1 100644
--- a/2_frontend/src/lib/streaming/streamLLM.ts
+++ b/2_frontend/src/lib/streaming/streamLLM.ts
@@ -40,10 +40,14 @@ export interface ClarifyCandidate {
reason: string
}
-/** `step` 이벤트 — 에이전트 그래프 진행 단계 (agent.iter 데모용). */
+/** `step` 이벤트 — 답변 전 과정 한 조각. reasoning 은 text 가 delta 로 누적, tool 은 같은 id 로 상태 갱신. */
export interface AgentStep {
- phase: string
- detail: string
+ id: string
+ kind: "reasoning" | "tool"
+ text?: string
+ tool?: string
+ status?: string
+ title?: string
}
/** `tool_call` 이벤트 — 모델이 도구 호출 시작. */
@@ -63,7 +67,7 @@ export interface LLMStreamHandlers {
onResult?: (data: TResult) => void
/** `clarify` 이벤트 — 라우팅 애매. 후보 받으면 token 없이 done으로 끝남 (선택) */
onClarify?: (candidates: ClarifyCandidate[]) => void
- /** `step` 이벤트 — 에이전트 진행 단계 (agent.iter 데모용, 선택) */
+ /** `step` 이벤트 — 생각/도구 과정 조각 (선택) */
onStep?: (step: AgentStep) => void
/** `tool_call` 이벤트 — 모델이 도구 호출 시작 (선택) */
onToolCall?: (call: AgentToolCall) => void
diff --git a/4_rust_tauri/src-tauri/Cargo.toml b/4_rust_tauri/src-tauri/Cargo.toml
index 38da60f..362a040 100644
--- a/4_rust_tauri/src-tauri/Cargo.toml
+++ b/4_rust_tauri/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "codeassist-tauri"
-version = "0.1.1"
+version = "0.1.2"
description = "CodeAssist 표준 Rust/Tauri 데스크톱 앱"
authors = ["justdodev"]
edition = "2021"
diff --git a/4_rust_tauri/src-tauri/tauri.conf.json b/4_rust_tauri/src-tauri/tauri.conf.json
index 9a7bd5a..9474490 100644
--- a/4_rust_tauri/src-tauri/tauri.conf.json
+++ b/4_rust_tauri/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeAssist",
- "version": "0.1.1",
+ "version": "0.1.2",
"identifier": "com.codeassist.app",
"build": {
"beforeDevCommand": { "cwd": "../../2_frontend", "script": "npm run dev" },
diff --git a/5_django_backend/apps/chat/stream.py b/5_django_backend/apps/chat/stream.py
index 24d63e6..1b96ea3 100644
--- a/5_django_backend/apps/chat/stream.py
+++ b/5_django_backend/apps/chat/stream.py
@@ -134,8 +134,12 @@ class TurnState:
if mid in self.user_message_ids and mid not in self.assistant_message_ids:
return out
pid = props.get("partID", "")
- if self.part_types.get(pid) != "text":
- return out # reasoning/tool 파트, 또는 아직 타입 모름(최종 스냅샷이 메워줌)
+ ptype = self.part_types.get(pid)
+ if ptype == "reasoning":
+ out.append(("step", {"id": pid, "kind": "reasoning", "text": props["delta"]}))
+ return out
+ if ptype != "text":
+ return out # tool 파트, 또는 아직 타입 모름(최종 스냅샷이 메워줌)
delta = props["delta"]
if pid not in self.text_by_part:
self.part_order.append(pid)
@@ -148,6 +152,11 @@ class TurnState:
part = props.get("part") or {}
if part.get("id"):
self.part_types[part["id"]] = part.get("type", "")
+ if part.get("type") == "tool":
+ # 도구 호출 진행 — 화면 "생각 과정" 에 이름·상태만. 입력/출력은 안 보냄(크고 사용자 관심 밖)
+ st = part.get("state") or {}
+ out.append(("step", {"id": part.get("id", ""), "kind": "tool", "tool": part.get("tool", ""), "status": st.get("status", ""), "title": st.get("title") or ""}))
+ return out
if part.get("type") != "text" or part.get("synthetic") or part.get("ignored"):
return out
mid = part.get("messageID", "")
diff --git a/5_django_backend/tests/test_stream.py b/5_django_backend/tests/test_stream.py
index cf6d6ff..5362f08 100644
--- a/5_django_backend/tests/test_stream.py
+++ b/5_django_backend/tests/test_stream.py
@@ -49,7 +49,7 @@ def test_turnstate_snapshot_path_without_delta():
def test_turnstate_ignores_reasoning_and_emits_title():
st = TurnState("s1", "q")
st.handle(_msg_updated("ma", "assistant"))
- assert st.handle(_part_updated("ma", "pr", "생각중", ptype="reasoning")) == []
+ assert st.handle(_part_updated("ma", "pr", "생각중", ptype="reasoning")) == [] # 스냅샷은 안 보냄(delta 만)
ev = {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "MARA 조회"}}}
assert st.handle(ev) == [("title", {"title": "MARA 조회"})]
assert st.handle(ev) == [] # 같은 제목 반복 안 보냄
@@ -57,6 +57,14 @@ def test_turnstate_ignores_reasoning_and_emits_title():
assert st.handle(placeholder) == [] # OpenCode 기본 제목은 무시
+def test_turnstate_tool_part_becomes_step():
+ st = TurnState("s1", "q")
+ st.handle(_msg_updated("ma", "assistant"))
+ ev = {"type": "message.part.updated", "properties": {"part": {"id": "pt1", "messageID": "ma", "sessionID": "s1", "type": "tool", "tool": "sap-icf_get_program_source", "state": {"status": "running", "title": "ZRMEM2016", "input": {"x": 1}}}}}
+ assert st.handle(ev) == [("step", {"id": "pt1", "kind": "tool", "tool": "sap-icf_get_program_source", "status": "running", "title": "ZRMEM2016"})]
+ assert st.accumulated() == "" # 도구는 답변 본문에 안 섞임
+
+
def _delta(mid, pid, delta, sid="s1", field="text"):
return {"type": "message.part.delta", "properties": {"sessionID": sid, "messageID": mid, "partID": pid, "field": field, "delta": delta}}
@@ -68,7 +76,7 @@ def test_turnstate_real_server_shape_delta_events():
st.handle(_part_updated("mu", "pu", "q"))
st.handle(_msg_updated("ma", "assistant"))
assert st.handle(_part_updated("ma", "pr", "", ptype="reasoning")) == []
- assert st.handle(_delta("ma", "pr", "생각")) == [] # reasoning delta 는 안 보냄
+ assert st.handle(_delta("ma", "pr", "생각")) == [("step", {"id": "pr", "kind": "reasoning", "text": "생각"})]
assert st.handle(_part_updated("ma", "pt", "")) == [] # 빈 스냅샷
assert st.handle(_delta("ma", "pt", "SELECT")) == [("token", {"delta": "SELECT"})]
assert st.handle(_delta("ma", "pt", " SINGLE")) == [("token", {"delta": " SINGLE"})]