Skip to content

Commit 4044c59

Browse files
committed
Add task board, plan tracking, and markdown table rendering
- Add TaskItem model and task lifecycle management (create, update, resolve temp IDs, prune stale/completed) in SessionState - Hook into TaskCreate/TaskUpdate/TaskList tool events via SessionStore.processTaskTracking for real-time task state sync - Parse tool_result and tool_response in Python hook script to extract resolved task IDs back to Swift - Add HookEvent fields: toolResult, resolvedTaskId, resolvedTaskSubject - Add collapsible Task Board UI above chat messages with per-task segmented progress bar, status icons, and spinning animation for in-progress tasks - Track ExitPlanMode tool calls to capture plan content and render it inline below the tool call in chat - Add Markdown.Table rendering support with styled header, borders, and rounded corners - Show project name as subtitle in chat header and instance list - Add PlanDetailView, PlanPreviewView, and PlanWindowController
1 parent 10f1d24 commit 4044c59

10 files changed

Lines changed: 652 additions & 20 deletions

File tree

ClaudeIsland/Models/SessionState.swift

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ struct SessionState: Equatable, Identifiable, Sendable {
4343
/// State for Task tools and their nested subagent tools
4444
var subagentState: SubagentState
4545

46+
// MARK: - Task Tracking
47+
48+
/// Tasks created via TaskCreate/TaskUpdate tool calls
49+
var tasks: [TaskItem]
50+
51+
// MARK: - Plan Content
52+
53+
/// Markdown content of the current plan (from ExitPlanMode)
54+
var planContent: String?
55+
56+
/// Path to the plan file for this session (resolved from ExitPlanMode events)
57+
var planFilePath: URL?
58+
4659
// MARK: - Conversation Info (from JSONL parsing)
4760

4861
var conversationInfo: ConversationInfo
@@ -75,6 +88,8 @@ struct SessionState: Equatable, Identifiable, Sendable {
7588
chatItems: [ChatHistoryItem] = [],
7689
toolTracker: ToolTracker = ToolTracker(),
7790
subagentState: SubagentState = SubagentState(),
91+
tasks: [TaskItem] = [],
92+
planContent: String? = nil,
7893
conversationInfo: ConversationInfo = ConversationInfo(
7994
summary: nil, lastMessage: nil, lastMessageRole: nil,
8095
lastToolName: nil, firstUserMessage: nil, lastUserMessageDate: nil
@@ -93,6 +108,8 @@ struct SessionState: Equatable, Identifiable, Sendable {
93108
self.chatItems = chatItems
94109
self.toolTracker = toolTracker
95110
self.subagentState = subagentState
111+
self.tasks = tasks
112+
self.planContent = planContent
96113
self.conversationInfo = conversationInfo
97114
self.needsClearReconciliation = needsClearReconciliation
98115
self.lastActivity = lastActivity
@@ -348,3 +365,67 @@ struct TaskContext: Equatable, Sendable {
348365
var description: String?
349366
var subagentTools: [SubagentToolCall]
350367
}
368+
369+
// MARK: - Task Item
370+
371+
/// A task tracked via TaskCreate/TaskUpdate tool calls
372+
struct TaskItem: Equatable, Identifiable, Sendable {
373+
var id: String
374+
var subject: String
375+
var status: TaskStatus
376+
377+
enum TaskStatus: String, Sendable {
378+
case pending
379+
case inProgress = "in_progress"
380+
case completed
381+
case deleted
382+
}
383+
}
384+
385+
// MARK: - Task Management
386+
387+
extension SessionState {
388+
/// Add a new task with a temporary placeholder ID
389+
mutating func addTask(subject: String) {
390+
let tempId = "_t\(tasks.count)"
391+
tasks.append(TaskItem(id: tempId, subject: subject, status: .pending))
392+
}
393+
394+
/// Reset all tasks for a new prompt turn
395+
mutating func resetTasks() {
396+
tasks.removeAll()
397+
}
398+
399+
/// Resolve a temp task's real ID by matching its subject
400+
mutating func resolveTaskId(subject: String, realId: String) {
401+
if let idx = tasks.firstIndex(where: { $0.id.hasPrefix("_t") && $0.subject == subject }) {
402+
tasks[idx].id = realId
403+
}
404+
}
405+
406+
/// Update task status by ID
407+
mutating func updateTask(taskId: String, status: TaskItem.TaskStatus) {
408+
if let idx = tasks.firstIndex(where: { $0.id == taskId }) {
409+
if status == .deleted {
410+
tasks.remove(at: idx)
411+
} else {
412+
tasks[idx].status = status
413+
}
414+
return
415+
}
416+
// Fallback: unknown task — create entry
417+
if status != .deleted {
418+
tasks.append(TaskItem(id: taskId, subject: "Task #\(taskId)", status: status))
419+
}
420+
}
421+
422+
/// Remove old pending tasks with temp IDs that were never resolved
423+
mutating func pruneStaleTemporaryTasks() {
424+
tasks.removeAll { $0.id.hasPrefix("_t") && $0.status == .pending }
425+
}
426+
427+
/// Remove completed tasks to keep the visible list concise
428+
mutating func pruneCompletedTasks() {
429+
tasks.removeAll { $0.status == .completed }
430+
}
431+
}

ClaudeIsland/Resources/claude-island-state.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,28 @@ def main():
113113
state["status"] = "processing"
114114
state["tool"] = data.get("tool_name")
115115
state["tool_input"] = tool_input
116+
# Forward tool_result for task ID resolution
117+
tool_result = data.get("tool_result")
118+
if tool_result:
119+
if isinstance(tool_result, str):
120+
state["tool_result"] = tool_result
121+
elif isinstance(tool_result, dict):
122+
# tool_result can be a dict with output/message/content
123+
state["tool_result"] = (
124+
tool_result.get("output")
125+
or tool_result.get("message")
126+
or tool_result.get("content")
127+
or ""
128+
)
129+
# Forward tool_response for structured results (e.g. TaskCreate → {task:{id:...,subject:...}})
130+
tool_response = data.get("tool_response")
131+
if isinstance(tool_response, dict):
132+
# Extract resolved task ID for TaskCreate
133+
task = tool_response.get("task")
134+
if isinstance(task, dict) and "id" in task:
135+
state["resolved_task_id"] = str(task["id"])
136+
if "subject" in task:
137+
state["resolved_task_subject"] = task["subject"]
116138
# Send tool_use_id so Swift can cancel the specific pending permission
117139
tool_use_id_from_event = data.get("tool_use_id")
118140
if tool_use_id_from_event:

ClaudeIsland/Services/Hooks/HookSocketServer.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ struct HookEvent: Codable, Sendable {
2525
let toolUseId: String?
2626
let notificationType: String?
2727
let message: String?
28+
let toolResult: String?
29+
let resolvedTaskId: String?
30+
let resolvedTaskSubject: String?
2831

2932
enum CodingKeys: String, CodingKey {
3033
case sessionId = "session_id"
@@ -33,10 +36,13 @@ struct HookEvent: Codable, Sendable {
3336
case toolUseId = "tool_use_id"
3437
case notificationType = "notification_type"
3538
case message
39+
case toolResult = "tool_result"
40+
case resolvedTaskId = "resolved_task_id"
41+
case resolvedTaskSubject = "resolved_task_subject"
3642
}
3743

3844
/// Create a copy with updated toolUseId
39-
init(sessionId: String, cwd: String, event: String, status: String, pid: Int?, tty: String?, tool: String?, toolInput: [String: AnyCodable]?, toolUseId: String?, notificationType: String?, message: String?) {
45+
init(sessionId: String, cwd: String, event: String, status: String, pid: Int?, tty: String?, tool: String?, toolInput: [String: AnyCodable]?, toolUseId: String?, notificationType: String?, message: String?, toolResult: String? = nil, resolvedTaskId: String? = nil, resolvedTaskSubject: String? = nil) {
4046
self.sessionId = sessionId
4147
self.cwd = cwd
4248
self.event = event
@@ -48,6 +54,9 @@ struct HookEvent: Codable, Sendable {
4854
self.toolUseId = toolUseId
4955
self.notificationType = notificationType
5056
self.message = message
57+
self.toolResult = toolResult
58+
self.resolvedTaskId = resolvedTaskId
59+
self.resolvedTaskSubject = resolvedTaskSubject
5160
}
5261

5362
var sessionPhase: SessionPhase {

ClaudeIsland/Services/State/SessionStore.swift

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,19 @@ actor SessionStore {
162162

163163
processToolTracking(event: event, session: &session)
164164
processSubagentTracking(event: event, session: &session)
165+
processTaskTracking(event: event, session: &session)
166+
167+
// Debug: log tool names to diagnose plan tracking
168+
if let tool = event.tool {
169+
Self.logger.debug("Hook event: \(event.event, privacy: .public) tool=\(tool, privacy: .public)")
170+
}
171+
processPlanTracking(event: event, session: &session)
172+
173+
if event.event == "UserPromptSubmit" {
174+
session.resetTasks()
175+
session.planContent = nil
176+
session.planFilePath = nil
177+
}
165178

166179
if event.event == "Stop" {
167180
session.subagentState = SubagentState()
@@ -318,6 +331,98 @@ actor SessionStore {
318331
}
319332
}
320333

334+
private func processTaskTracking(event: HookEvent, session: inout SessionState) {
335+
switch event.event {
336+
case "PreToolUse":
337+
guard let toolName = event.tool else { return }
338+
339+
if toolName == "TaskCreate" {
340+
if let subject = event.toolInput?["subject"]?.value as? String {
341+
session.addTask(subject: subject)
342+
Self.logger.debug("TaskCreate: added task '\(subject, privacy: .public)'")
343+
}
344+
} else if toolName == "TaskUpdate" {
345+
if let taskId = event.toolInput?["taskId"]?.value as? String {
346+
let existingIds = session.tasks.map { $0.id }.joined(separator: ", ")
347+
Self.logger.debug("TaskUpdate: looking for \(taskId, privacy: .public) in [\(existingIds, privacy: .public)]")
348+
if let statusStr = event.toolInput?["status"]?.value as? String,
349+
let status = TaskItem.TaskStatus(rawValue: statusStr) {
350+
session.updateTask(taskId: taskId, status: status)
351+
Self.logger.debug("TaskUpdate: \(taskId, privacy: .public)\(statusStr, privacy: .public)")
352+
}
353+
if let subject = event.toolInput?["subject"]?.value as? String,
354+
let idx = session.tasks.firstIndex(where: { $0.id == taskId }) {
355+
session.tasks[idx].subject = subject
356+
}
357+
}
358+
} else if toolName == "TaskList" {
359+
session.pruneStaleTemporaryTasks()
360+
session.pruneCompletedTasks()
361+
}
362+
363+
case "PostToolUse":
364+
guard event.tool == "TaskCreate" else { return }
365+
366+
// Primary: use resolved_task_id from tool_response (parsed by Python)
367+
if let realId = event.resolvedTaskId {
368+
let subject = event.resolvedTaskSubject
369+
?? event.toolInput?["subject"]?.value as? String
370+
if let subject {
371+
session.resolveTaskId(subject: subject, realId: realId)
372+
} else if let idx = session.tasks.firstIndex(where: { $0.id.hasPrefix("_t") }) {
373+
session.tasks[idx].id = realId
374+
}
375+
Self.logger.debug("TaskCreate resolved: id=\(realId, privacy: .public)")
376+
}
377+
// Fallback: parse from tool_result string "Task #8 created..."
378+
else if let result = event.toolResult {
379+
if let range = result.range(of: #"Task #(\d+)"#, options: .regularExpression),
380+
let idRange = result[range].range(of: #"\d+"#, options: .regularExpression) {
381+
let realId = String(result[idRange])
382+
if let inputSubject = event.toolInput?["subject"]?.value as? String {
383+
session.resolveTaskId(subject: inputSubject, realId: realId)
384+
} else if let idx = session.tasks.firstIndex(where: { $0.id.hasPrefix("_t") }) {
385+
session.tasks[idx].id = realId
386+
}
387+
Self.logger.debug("TaskCreate resolved (fallback): id=\(realId, privacy: .public)")
388+
}
389+
}
390+
391+
default:
392+
break
393+
}
394+
}
395+
396+
// MARK: - Plan Tracking
397+
398+
private func processPlanTracking(event: HookEvent, session: inout SessionState) {
399+
guard let toolName = event.tool else { return }
400+
let isPlanTool = toolName == "ExitPlanMode"
401+
|| toolName == "exit_plan_mode"
402+
|| toolName.lowercased().contains("exitplanmode")
403+
|| toolName.lowercased().contains("planmode")
404+
405+
guard isPlanTool else { return }
406+
407+
// Extract plan content and file path directly from toolInput
408+
if let input = event.toolInput {
409+
if let planContent = input["plan"]?.value as? String, !planContent.isEmpty {
410+
session.planContent = planContent
411+
Self.logger.debug("Plan loaded from toolInput (\(planContent.count) chars)")
412+
}
413+
if let pathStr = input["planFilePath"]?.value as? String, !pathStr.isEmpty {
414+
session.planFilePath = URL(fileURLWithPath: pathStr)
415+
}
416+
}
417+
418+
// Fallback: read from known file path
419+
if session.planContent == nil, let path = session.planFilePath,
420+
let content = try? String(contentsOf: path, encoding: .utf8) {
421+
session.planContent = content
422+
Self.logger.debug("Plan loaded from file: \(path.lastPathComponent, privacy: .public)")
423+
}
424+
}
425+
321426
/// Push the current subagent tool lists from subagentState into the
322427
/// corresponding ChatHistoryItem.subagentTools so the UI renders them live.
323428
private func syncSubagentToolsToChatItems(session: inout SessionState) {

ClaudeIsland/UI/Components/MarkdownRenderer.swift

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ private struct BlockRenderer: View {
9999
Divider()
100100
.background(baseColor.opacity(0.3))
101101
.padding(.vertical, 4)
102+
} else if let table = markup as? Markdown.Table {
103+
tableView(table)
102104
} else {
103105
EmptyView()
104106
}
@@ -181,6 +183,65 @@ private struct BlockRenderer: View {
181183
}
182184
}
183185
}
186+
187+
@ViewBuilder
188+
private func tableView(_ table: Markdown.Table) -> some View {
189+
let headCells = Array(table.head.cells)
190+
let bodyRows = Array(table.body.rows)
191+
192+
VStack(alignment: .leading, spacing: 0) {
193+
// Header row
194+
HStack(spacing: 0) {
195+
ForEach(Array(headCells.enumerated()), id: \.offset) { i, cell in
196+
cellPlainText(cell)
197+
.fontWeight(.semibold)
198+
.foregroundColor(baseColor)
199+
.font(.system(size: fontSize - 1))
200+
.padding(.horizontal, 8)
201+
.padding(.vertical, 5)
202+
.frame(maxWidth: .infinity, alignment: .leading)
203+
204+
if i < headCells.count - 1 {
205+
Rectangle().fill(baseColor.opacity(0.15)).frame(width: 0.5)
206+
}
207+
}
208+
}
209+
.background(baseColor.opacity(0.08))
210+
211+
Rectangle().fill(baseColor.opacity(0.2)).frame(height: 0.5)
212+
213+
// Body rows
214+
ForEach(Array(bodyRows.enumerated()), id: \.offset) { _, row in
215+
let cells = Array(row.cells)
216+
HStack(spacing: 0) {
217+
ForEach(Array(cells.enumerated()), id: \.offset) { i, cell in
218+
cellPlainText(cell)
219+
.foregroundColor(baseColor.opacity(0.8))
220+
.font(.system(size: fontSize - 1))
221+
.padding(.horizontal, 8)
222+
.padding(.vertical, 4)
223+
.frame(maxWidth: .infinity, alignment: .leading)
224+
225+
if i < cells.count - 1 {
226+
Rectangle().fill(baseColor.opacity(0.1)).frame(width: 0.5)
227+
}
228+
}
229+
}
230+
231+
Rectangle().fill(baseColor.opacity(0.08)).frame(height: 0.5)
232+
}
233+
}
234+
.clipShape(RoundedRectangle(cornerRadius: 6))
235+
.overlay(
236+
RoundedRectangle(cornerRadius: 6)
237+
.stroke(baseColor.opacity(0.15), lineWidth: 0.5)
238+
)
239+
}
240+
241+
/// Simple plain text extraction from a table cell
242+
private func cellPlainText(_ cell: Markdown.Table.Cell) -> SwiftUI.Text {
243+
SwiftUI.Text(cell.plainText)
244+
}
184245
}
185246

186247
// MARK: - Inline Renderer

0 commit comments

Comments
 (0)