Add task history

This commit is contained in:
Saoud Rizwan
2024-08-17 08:29:30 -04:00
parent 38f98951d0
commit d1437e6d2d
18 changed files with 1211 additions and 137 deletions

View File

@@ -53,6 +53,11 @@
"title": "New Task", "title": "New Task",
"icon": "$(add)" "icon": "$(add)"
}, },
{
"command": "claude-dev.historyButtonTapped",
"title": "History",
"icon": "$(history)"
},
{ {
"command": "claude-dev.popoutButtonTapped", "command": "claude-dev.popoutButtonTapped",
"title": "Open in Editor", "title": "Open in Editor",
@@ -73,17 +78,22 @@
"view/title": [ "view/title": [
{ {
"command": "claude-dev.plusButtonTapped", "command": "claude-dev.plusButtonTapped",
"group": "navigation", "group": "navigation@1",
"when": "view == claude-dev.SidebarProvider"
},
{
"command": "claude-dev.historyButtonTapped",
"group": "navigation@2",
"when": "view == claude-dev.SidebarProvider" "when": "view == claude-dev.SidebarProvider"
}, },
{ {
"command": "claude-dev.popoutButtonTapped", "command": "claude-dev.popoutButtonTapped",
"group": "navigation", "group": "navigation@3",
"when": "view == claude-dev.SidebarProvider" "when": "view == claude-dev.SidebarProvider"
}, },
{ {
"command": "claude-dev.settingsButtonTapped", "command": "claude-dev.settingsButtonTapped",
"group": "navigation", "group": "navigation@4",
"when": "view == claude-dev.SidebarProvider" "when": "view == claude-dev.SidebarProvider"
} }
] ]

View File

@@ -20,6 +20,10 @@ import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "./shared/Ext
import { Tool, ToolName } from "./shared/Tool" import { Tool, ToolName } from "./shared/Tool"
import { ClaudeAskResponse } from "./shared/WebviewMessage" import { ClaudeAskResponse } from "./shared/WebviewMessage"
import delay from "delay" import delay from "delay"
import { getApiMetrics } from "./shared/getApiMetrics"
import { HistoryItem } from "./shared/HistoryItem"
import { combineApiRequests } from "./shared/combineApiRequests"
import { combineCommandSequences } from "./shared/combineCommandSequences"
const SYSTEM_PROMPT = const SYSTEM_PROMPT =
() => `You are Claude Dev, a highly skilled software developer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices. () => `You are Claude Dev, a highly skilled software developer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
@@ -219,8 +223,12 @@ const tools: Tool[] = [
] ]
type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam> type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<
Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam
>
export class ClaudeDev { export class ClaudeDev {
readonly taskId: string
private api: ApiHandler private api: ApiHandler
private maxRequestsPerTask: number private maxRequestsPerTask: number
private customInstructions?: string private customInstructions?: string
@@ -241,14 +249,23 @@ export class ClaudeDev {
maxRequestsPerTask?: number, maxRequestsPerTask?: number,
customInstructions?: string, customInstructions?: string,
task?: string, task?: string,
images?: string[] images?: string[],
historyItem?: HistoryItem
) { ) {
this.providerRef = new WeakRef(provider) this.providerRef = new WeakRef(provider)
this.api = buildApiHandler(apiConfiguration) this.api = buildApiHandler(apiConfiguration)
this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK
this.customInstructions = customInstructions this.customInstructions = customInstructions
if (historyItem) {
this.taskId = historyItem.id
this.resumeTaskFromHistory()
} else if (task || images) {
this.taskId = Date.now().toString()
this.startTask(task, images) this.startTask(task, images)
} else {
throw new Error("Either historyItem or task/images must be provided")
}
} }
updateApi(apiConfiguration: ApiConfiguration) { updateApi(apiConfiguration: ApiConfiguration) {
@@ -269,9 +286,97 @@ export class ClaudeDev {
this.askResponseImages = images this.askResponseImages = images
} }
// storing task to disk for history
private async ensureTaskDirectoryExists(): Promise<string> {
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const taskDir = path.join(globalStoragePath, "tasks", this.taskId)
await fs.mkdir(taskDir, { recursive: true })
return taskDir
}
private async getSavedApiConversationHistory(): Promise<Anthropic.MessageParam[]> {
const filePath = path.join(await this.ensureTaskDirectoryExists(), "api_conversation_history.json")
const fileExists = await fs
.access(filePath)
.then(() => true)
.catch(() => false)
if (fileExists) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
}
return []
}
private async addToApiConversationHistory(message: Anthropic.MessageParam) {
this.apiConversationHistory.push(message)
await this.saveApiConversationHistory()
}
private async overwriteApiConversationHistory(newHistory: Anthropic.MessageParam[]) {
this.apiConversationHistory = newHistory
await this.saveApiConversationHistory()
}
private async saveApiConversationHistory() {
try {
const filePath = path.join(await this.ensureTaskDirectoryExists(), "api_conversation_history.json")
await fs.writeFile(filePath, JSON.stringify(this.apiConversationHistory))
} catch (error) {
// in the off chance this fails, we don't want to stop the task
console.error("Failed to save API conversation history:", error)
}
}
private async getSavedClaudeMessages(): Promise<ClaudeMessage[]> {
const filePath = path.join(await this.ensureTaskDirectoryExists(), "claude_messages.json")
const fileExists = await fs
.access(filePath)
.then(() => true)
.catch(() => false)
if (fileExists) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
}
return []
}
private async addToClaudeMessages(message: ClaudeMessage) {
this.claudeMessages.push(message)
await this.saveClaudeMessages()
}
private async overwriteClaudeMessages(newMessages: ClaudeMessage[]) {
this.claudeMessages = newMessages
await this.saveClaudeMessages()
}
private async saveClaudeMessages() {
try {
const filePath = path.join(await this.ensureTaskDirectoryExists(), "claude_messages.json")
await fs.writeFile(filePath, JSON.stringify(this.claudeMessages))
// combined as they are in ChatView
const apiMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(this.claudeMessages.slice(1))))
const taskMessage = this.claudeMessages[0] // first message is always the task say
await this.providerRef.deref()?.updateTaskHistory({
id: this.taskId,
ts: taskMessage.ts,
task: taskMessage.text ?? "",
tokensIn: apiMetrics.totalTokensIn,
tokensOut: apiMetrics.totalTokensOut,
cacheWrites: apiMetrics.totalCacheWrites,
cacheReads: apiMetrics.totalCacheReads,
totalCost: apiMetrics.totalCost,
})
} catch (error) {
console.error("Failed to save claude messages:", error)
}
}
async ask( async ask(
type: ClaudeAsk, type: ClaudeAsk,
question: string question?: string
): Promise<{ response: ClaudeAskResponse; text?: string; images?: string[] }> { ): Promise<{ response: ClaudeAskResponse; text?: string; images?: string[] }> {
// If this ClaudeDev instance was aborted by the provider, then the only thing keeping us alive is a promise still running in the background, in which case we don't want to send its result to the webview as it is attached to a new instance of ClaudeDev now. So we can safely ignore the result of any active promises, and this class will be deallocated. (Although we set claudeDev = undefined in provider, that simply removes the reference to this instance, but the instance is still alive until this promise resolves or rejects.) // If this ClaudeDev instance was aborted by the provider, then the only thing keeping us alive is a promise still running in the background, in which case we don't want to send its result to the webview as it is attached to a new instance of ClaudeDev now. So we can safely ignore the result of any active promises, and this class will be deallocated. (Although we set claudeDev = undefined in provider, that simply removes the reference to this instance, but the instance is still alive until this promise resolves or rejects.)
if (this.abort) { if (this.abort) {
@@ -282,7 +387,7 @@ export class ClaudeDev {
this.askResponseImages = undefined this.askResponseImages = undefined
const askTs = Date.now() const askTs = Date.now()
this.lastMessageTs = askTs this.lastMessageTs = askTs
this.claudeMessages.push({ ts: askTs, type: "ask", ask: type, text: question }) await this.addToClaudeMessages({ ts: askTs, type: "ask", ask: type, text: question })
await this.providerRef.deref()?.postStateToWebview() await this.providerRef.deref()?.postStateToWebview()
await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 })
if (this.lastMessageTs !== askTs) { if (this.lastMessageTs !== askTs) {
@@ -301,7 +406,7 @@ export class ClaudeDev {
} }
const sayTs = Date.now() const sayTs = Date.now()
this.lastMessageTs = sayTs this.lastMessageTs = sayTs
this.claudeMessages.push({ ts: sayTs, type: "say", say: type, text: text, images }) await this.addToClaudeMessages({ ts: sayTs, type: "say", say: type, text: text, images })
await this.providerRef.deref()?.postStateToWebview() await this.providerRef.deref()?.postStateToWebview()
} }
@@ -342,22 +447,177 @@ export class ClaudeDev {
text: `<task>\n${task}\n</task>\n\n${this.getPotentiallyRelevantDetails()}`, // cannot be sent with system prompt since it's cached and these details can change text: `<task>\n${task}\n</task>\n\n${this.getPotentiallyRelevantDetails()}`, // cannot be sent with system prompt since it's cached and these details can change
} }
let imageBlocks: Anthropic.ImageBlockParam[] = this.formatImagesIntoBlocks(images) let imageBlocks: Anthropic.ImageBlockParam[] = this.formatImagesIntoBlocks(images)
// TODO: create tools that let Claude interact with VSCode (e.g. open a file, list open files, etc.)
//const openFiles = vscode.window.visibleTextEditors?.map((editor) => editor.document.uri.fsPath).join("\n")
await this.say("text", task, images) await this.say("text", task, images)
await this.initiateTaskLoop([textBlock, ...imageBlocks])
}
let totalInputTokens = 0 private async resumeTaskFromHistory() {
let totalOutputTokens = 0 const modifiedClaudeMessages = await this.getSavedClaudeMessages()
// need to modify claude messages for good ux, i.e. if the last message is an api_request_started, then remove it otherwise the user will think the request is still loading
const lastApiReqStartedIndex = modifiedClaudeMessages.reduce(
(lastIndex, m, index) => (m.type === "say" && m.say === "api_req_started" ? index : lastIndex),
-1
)
const lastApiReqFinishedIndex = modifiedClaudeMessages.reduce(
(lastIndex, m, index) => (m.type === "say" && m.say === "api_req_finished" ? index : lastIndex),
-1
)
if (lastApiReqStartedIndex > lastApiReqFinishedIndex && lastApiReqStartedIndex !== -1) {
modifiedClaudeMessages.splice(lastApiReqStartedIndex, 1)
}
await this.overwriteClaudeMessages(modifiedClaudeMessages)
this.claudeMessages = await this.getSavedClaudeMessages()
// Now present the claude messages to the user and ask if they want to resume
const lastClaudeMessage = this.claudeMessages
.slice()
.reverse()
.find((m) => m.ask !== "resume_task") // could be multiple resume tasks
const { response, text, images } = await this.ask("resume_task") // calls poststatetowebview
let newUserContent: UserContent = []
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
if (images && images.length > 0) {
newUserContent.push(...this.formatImagesIntoBlocks(images))
}
if (text) {
newUserContent.push({ type: "text", text })
}
}
// need to make sure that the api conversation history can be resumed by the api, even if it goes out of sync with claude messages
// if the last message is an assistant message, we need to check if there's tool use since every tool use has to have a tool response
// if there's no tool use and only a text block, then we can just add a user message
// if the last message is a user message, we can need to get the assistant message before it to see if it made tool calls, and if so, fill in the remaining tool responses with 'interrupted'
const existingApiConversationHistory: Anthropic.Messages.MessageParam[] =
await this.getSavedApiConversationHistory()
let modifiedOldUserContent: UserContent
let modifiedApiConversationHistory: Anthropic.Messages.MessageParam[]
if (existingApiConversationHistory.length > 0) {
const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1]
if (lastMessage.role === "assistant") {
const content = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text", text: lastMessage.content }]
const hasToolUse = content.some((block) => block.type === "tool_use")
if (hasToolUse) {
const toolUseBlocks = content.filter(
(block) => block.type === "tool_use"
) as Anthropic.Messages.ToolUseBlock[]
const toolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks.map((block) => ({
type: "tool_result",
tool_use_id: block.id,
content: "Task was interrupted before this tool call could be completed.",
}))
modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes
modifiedOldUserContent = [...toolResponses]
} else {
modifiedApiConversationHistory = [...existingApiConversationHistory]
modifiedOldUserContent = []
}
} else if (lastMessage.role === "user") {
const previousAssistantMessage =
existingApiConversationHistory[existingApiConversationHistory.length - 2]
const existingUserContent: UserContent = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text", text: lastMessage.content }]
if (previousAssistantMessage && previousAssistantMessage.role === "assistant") {
const assistantContent = Array.isArray(previousAssistantMessage.content)
? previousAssistantMessage.content
: [{ type: "text", text: previousAssistantMessage.content }]
const toolUseBlocks = assistantContent.filter(
(block) => block.type === "tool_use"
) as Anthropic.Messages.ToolUseBlock[]
if (toolUseBlocks.length > 0) {
const existingToolResults = existingUserContent.filter(
(block) => block.type === "tool_result"
) as Anthropic.ToolResultBlockParam[]
const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks
.filter(
(toolUse) => !existingToolResults.some((result) => result.tool_use_id === toolUse.id)
)
.map((toolUse) => ({
type: "tool_result",
tool_use_id: toolUse.id,
content: "Task was interrupted before this tool call could be completed.",
}))
modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
modifiedOldUserContent = [...existingUserContent, ...missingToolResponses]
} else {
modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
modifiedOldUserContent = [...existingUserContent]
}
} else {
modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
modifiedOldUserContent = [...existingUserContent]
}
} else {
throw new Error("Unexpected: Last message is not a user or assistant message")
}
} else {
throw new Error("Unexpected: No existing API conversation history")
}
// now we have newUserContent which is user's current message, and the modifiedOldUserContent which is the old message with tool responses filled in
// we need to combine them while ensuring there is only one text block
const modifiedOldUserContentText = modifiedOldUserContent.find((block) => block.type === "text")?.text
const newUserContentText = newUserContent.find((block) => block.type === "text")?.text
const agoText = (() => {
const timestamp = lastClaudeMessage?.ts ?? Date.now()
const now = Date.now()
const diff = now - timestamp
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) {
return `${days} day${days > 1 ? "s" : ""} ago`
}
if (hours > 0) {
return `${hours} hour${hours > 1 ? "s" : ""} ago`
}
if (minutes > 0) {
return `${minutes} minute${minutes > 1 ? "s" : ""} ago`
}
return "just now"
})()
const combinedText =
`Task resumption: This autonomous coding task was interrupted ${agoText}. It may or may not be complete. Be aware that the conversation history and project state may have changed since then. The current working directory is now ${cwd}. Please reassess the task context before proceeding.` +
(modifiedOldUserContentText
? `\n\nLast recorded user input before interruption:\n<previous_message>\n${modifiedOldUserContentText}\n</previous_message>\n`
: "") +
(newUserContentText
? `\n\nNew instructions for task continuation:\n<user_message>\n${newUserContentText}\n</user_message>\n`
: "") +
`\n\n${this.getPotentiallyRelevantDetails()}`
const combinedModifiedOldUserContentWithNewUserContent: UserContent = (
modifiedOldUserContent.filter((block) => block.type !== "text") as UserContent
).concat([{ type: "text", text: combinedText }])
await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
await this.initiateTaskLoop(combinedModifiedOldUserContentWithNewUserContent)
}
private async initiateTaskLoop(userContent: UserContent): Promise<void> {
let nextUserContent = userContent
while (!this.abort) { while (!this.abort) {
const { didEndLoop, inputTokens, outputTokens } = await this.recursivelyMakeClaudeRequests([ const { didEndLoop } = await this.recursivelyMakeClaudeRequests(nextUserContent)
textBlock,
...imageBlocks,
])
totalInputTokens += inputTokens
totalOutputTokens += outputTokens
// The way this agentic loop works is that claude will be given a task that he then calls tools to complete. unless there's an attempt_completion call, we keep responding back to him with his tool's responses until he either attempt_completion or does not use anymore tools. If he does not use anymore tools, we ask him to consider if he's completed the task and then call attempt_completion, otherwise proceed with completing the task. // The way this agentic loop works is that claude will be given a task that he then calls tools to complete. unless there's an attempt_completion call, we keep responding back to him with his tool's responses until he either attempt_completion or does not use anymore tools. If he does not use anymore tools, we ask him to consider if he's completed the task and then call attempt_completion, otherwise proceed with completing the task.
// There is a MAX_REQUESTS_PER_TASK limit to prevent infinite requests, but Claude is prompted to finish the task as efficiently as he can. // There is a MAX_REQUESTS_PER_TASK limit to prevent infinite requests, but Claude is prompted to finish the task as efficiently as he can.
@@ -372,11 +632,12 @@ export class ClaudeDev {
// "tool", // "tool",
// "Claude responded with only text blocks but has not called attempt_completion yet. Forcing him to continue with task..." // "Claude responded with only text blocks but has not called attempt_completion yet. Forcing him to continue with task..."
// ) // )
textBlock = { nextUserContent = [
{
type: "text", type: "text",
text: "If you have completed the user's task, use the attempt_completion tool. If you require additional information from the user, use the ask_followup_question tool. Otherwise, if you have not completed the task and do not need additional information, then proceed with the next step of the task. (This is an automated message, so do not respond to it conversationally.)", text: "If you have completed the user's task, use the attempt_completion tool. If you require additional information from the user, use the ask_followup_question tool. Otherwise, if you have not completed the task and do not need additional information, then proceed with the next step of the task. (This is an automated message, so do not respond to it conversationally.)",
} },
imageBlocks = [] ]
} }
} }
} }
@@ -436,7 +697,7 @@ export class ClaudeDev {
async writeToFile(relPath?: string, newContent?: string, isLast: boolean = true): Promise<ToolResponse> { async writeToFile(relPath?: string, newContent?: string, isLast: boolean = true): Promise<ToolResponse> {
if (relPath === undefined) { if (relPath === undefined) {
this.say( await this.say(
"error", "error",
"Claude tried to use write_to_file without value for required parameter 'path'. Retrying..." "Claude tried to use write_to_file without value for required parameter 'path'. Retrying..."
) )
@@ -445,7 +706,7 @@ export class ClaudeDev {
if (newContent === undefined) { if (newContent === undefined) {
// Special message for this case since this tends to happen the most // Special message for this case since this tends to happen the most
this.say( await this.say(
"error", "error",
`Claude tried to use write_to_file for '${relPath}' without value for required parameter 'content'. This is likely due to output token limits. Retrying...` `Claude tried to use write_to_file for '${relPath}' without value for required parameter 'content'. This is likely due to output token limits. Retrying...`
) )
@@ -557,7 +818,10 @@ export class ClaudeDev {
} }
} catch (error) { } catch (error) {
const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}` const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}`
this.say("error", `Error writing file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`) await this.say(
"error",
`Error writing file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`
)
return errorString return errorString
} }
} }
@@ -577,7 +841,10 @@ export class ClaudeDev {
async readFile(relPath?: string): Promise<ToolResponse> { async readFile(relPath?: string): Promise<ToolResponse> {
if (relPath === undefined) { if (relPath === undefined) {
this.say("error", "Claude tried to use read_file without value for required parameter 'path'. Retrying...") await this.say(
"error",
"Claude tried to use read_file without value for required parameter 'path'. Retrying..."
)
return "Error: Missing value for required parameter 'path'. Please retry with complete response." return "Error: Missing value for required parameter 'path'. Please retry with complete response."
} }
try { try {
@@ -597,14 +864,17 @@ export class ClaudeDev {
return content return content
} catch (error) { } catch (error) {
const errorString = `Error reading file: ${JSON.stringify(serializeError(error))}` const errorString = `Error reading file: ${JSON.stringify(serializeError(error))}`
this.say("error", `Error reading file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`) await this.say(
"error",
`Error reading file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`
)
return errorString return errorString
} }
} }
async listFilesTopLevel(relDirPath?: string): Promise<ToolResponse> { async listFilesTopLevel(relDirPath?: string): Promise<ToolResponse> {
if (relDirPath === undefined) { if (relDirPath === undefined) {
this.say( await this.say(
"error", "error",
"Claude tried to use list_files_top_level without value for required parameter 'path'. Retrying..." "Claude tried to use list_files_top_level without value for required parameter 'path'. Retrying..."
) )
@@ -632,7 +902,7 @@ export class ClaudeDev {
return result return result
} catch (error) { } catch (error) {
const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}` const errorString = `Error listing files and directories: ${JSON.stringify(serializeError(error))}`
this.say( await this.say(
"error", "error",
`Error listing files and directories:\n${ `Error listing files and directories:\n${
error.message ?? JSON.stringify(serializeError(error), null, 2) error.message ?? JSON.stringify(serializeError(error), null, 2)
@@ -644,7 +914,7 @@ export class ClaudeDev {
async listFilesRecursive(relDirPath?: string): Promise<ToolResponse> { async listFilesRecursive(relDirPath?: string): Promise<ToolResponse> {
if (relDirPath === undefined) { if (relDirPath === undefined) {
this.say( await this.say(
"error", "error",
"Claude tried to use list_files_recursive without value for required parameter 'path'. Retrying..." "Claude tried to use list_files_recursive without value for required parameter 'path'. Retrying..."
) )
@@ -672,7 +942,7 @@ export class ClaudeDev {
return result return result
} catch (error) { } catch (error) {
const errorString = `Error listing files recursively: ${JSON.stringify(serializeError(error))}` const errorString = `Error listing files recursively: ${JSON.stringify(serializeError(error))}`
this.say( await this.say(
"error", "error",
`Error listing files recursively:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}` `Error listing files recursively:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`
) )
@@ -731,7 +1001,7 @@ export class ClaudeDev {
async viewSourceCodeDefinitionsTopLevel(relDirPath?: string): Promise<ToolResponse> { async viewSourceCodeDefinitionsTopLevel(relDirPath?: string): Promise<ToolResponse> {
if (relDirPath === undefined) { if (relDirPath === undefined) {
this.say( await this.say(
"error", "error",
"Claude tried to use view_source_code_definitions_top_level without value for required parameter 'path'. Retrying..." "Claude tried to use view_source_code_definitions_top_level without value for required parameter 'path'. Retrying..."
) )
@@ -758,7 +1028,7 @@ export class ClaudeDev {
return result return result
} catch (error) { } catch (error) {
const errorString = `Error parsing source code definitions: ${JSON.stringify(serializeError(error))}` const errorString = `Error parsing source code definitions: ${JSON.stringify(serializeError(error))}`
this.say( await this.say(
"error", "error",
`Error parsing source code definitions:\n${ `Error parsing source code definitions:\n${
error.message ?? JSON.stringify(serializeError(error), null, 2) error.message ?? JSON.stringify(serializeError(error), null, 2)
@@ -770,7 +1040,7 @@ export class ClaudeDev {
async executeCommand(command?: string, returnEmptyStringOnSuccess: boolean = false): Promise<ToolResponse> { async executeCommand(command?: string, returnEmptyStringOnSuccess: boolean = false): Promise<ToolResponse> {
if (command === undefined) { if (command === undefined) {
this.say( await this.say(
"error", "error",
"Claude tried to use execute_command without value for required parameter 'command'. Retrying..." "Claude tried to use execute_command without value for required parameter 'command'. Retrying..."
) )
@@ -863,7 +1133,7 @@ export class ClaudeDev {
const error = e as any const error = e as any
let errorMessage = error.message || JSON.stringify(serializeError(error), null, 2) let errorMessage = error.message || JSON.stringify(serializeError(error), null, 2)
const errorString = `Error executing command:\n${errorMessage}` const errorString = `Error executing command:\n${errorMessage}`
this.say("error", `Error executing command:\n${errorMessage}`) // TODO: in webview show code block for command errors await this.say("error", `Error executing command:\n${errorMessage}`) // TODO: in webview show code block for command errors
this.executeCommandRunningProcess = undefined this.executeCommandRunningProcess = undefined
return errorString return errorString
} }
@@ -871,7 +1141,7 @@ export class ClaudeDev {
async askFollowupQuestion(question?: string): Promise<ToolResponse> { async askFollowupQuestion(question?: string): Promise<ToolResponse> {
if (question === undefined) { if (question === undefined) {
this.say( await this.say(
"error", "error",
"Claude tried to use ask_followup_question without value for required parameter 'question'. Retrying..." "Claude tried to use ask_followup_question without value for required parameter 'question'. Retrying..."
) )
@@ -885,7 +1155,7 @@ export class ClaudeDev {
async attemptCompletion(result?: string, command?: string): Promise<ToolResponse> { async attemptCompletion(result?: string, command?: string): Promise<ToolResponse> {
// result is required, command is optional // result is required, command is optional
if (result === undefined) { if (result === undefined) {
this.say( await this.say(
"error", "error",
"Claude tried to use attempt_completion without value for required parameter 'result'. Retrying..." "Claude tried to use attempt_completion without value for required parameter 'result'. Retrying..."
) )
@@ -943,19 +1213,12 @@ ${this.customInstructions.trim()}
} }
} }
async recursivelyMakeClaudeRequests( async recursivelyMakeClaudeRequests(userContent: UserContent): Promise<ClaudeRequestResult> {
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): Promise<ClaudeRequestResult> {
if (this.abort) { if (this.abort) {
throw new Error("ClaudeDev instance aborted") throw new Error("ClaudeDev instance aborted")
} }
this.apiConversationHistory.push({ role: "user", content: userContent }) await this.addToApiConversationHistory({ role: "user", content: userContent })
if (this.requestCount >= this.maxRequestsPerTask) { if (this.requestCount >= this.maxRequestsPerTask) {
const { response } = await this.ask( const { response } = await this.ask(
"request_limit_reached", "request_limit_reached",
@@ -965,7 +1228,7 @@ ${this.customInstructions.trim()}
if (response === "yesButtonTapped") { if (response === "yesButtonTapped") {
this.requestCount = 0 this.requestCount = 0
} else { } else {
this.apiConversationHistory.push({ await this.addToApiConversationHistory({
role: "assistant", role: "assistant",
content: [ content: [
{ {
@@ -989,6 +1252,10 @@ ${this.customInstructions.trim()}
const response = await this.attemptApiRequest() const response = await this.attemptApiRequest()
this.requestCount++ this.requestCount++
if (this.abort) {
throw new Error("ClaudeDev instance aborted")
}
let assistantResponses: Anthropic.Messages.ContentBlock[] = [] let assistantResponses: Anthropic.Messages.ContentBlock[] = []
let inputTokens = response.usage.input_tokens let inputTokens = response.usage.input_tokens
let outputTokens = response.usage.output_tokens let outputTokens = response.usage.output_tokens
@@ -1056,11 +1323,11 @@ ${this.customInstructions.trim()}
} }
if (assistantResponses.length > 0) { if (assistantResponses.length > 0) {
this.apiConversationHistory.push({ role: "assistant", content: assistantResponses }) await this.addToApiConversationHistory({ role: "assistant", content: assistantResponses })
} else { } else {
// this should never happen! it there's no assistant_responses, that means we got no text or tool_use content blocks from API which we should assume is an error // this should never happen! it there's no assistant_responses, that means we got no text or tool_use content blocks from API which we should assume is an error
this.say("error", "Unexpected Error: No assistant messages were found in the API response") await this.say("error", "Unexpected Error: No assistant messages were found in the API response")
this.apiConversationHistory.push({ await this.addToApiConversationHistory({
role: "assistant", role: "assistant",
content: [{ type: "text", text: "Failure: I did not have a response to provide." }], content: [{ type: "text", text: "Failure: I did not have a response to provide." }],
}) })
@@ -1090,8 +1357,8 @@ ${this.customInstructions.trim()}
if (toolResults.length > 0) { if (toolResults.length > 0) {
if (didEndLoop) { if (didEndLoop) {
this.apiConversationHistory.push({ role: "user", content: toolResults }) await this.addToApiConversationHistory({ role: "user", content: toolResults })
this.apiConversationHistory.push({ await this.addToApiConversationHistory({
role: "assistant", role: "assistant",
content: [ content: [
{ {

View File

@@ -49,7 +49,7 @@ export function activate(context: vscode.ExtensionContext) {
outputChannel.appendLine("Plus button tapped") outputChannel.appendLine("Plus button tapped")
await sidebarProvider.clearTask() await sidebarProvider.clearTask()
await sidebarProvider.postStateToWebview() await sidebarProvider.postStateToWebview()
await sidebarProvider.postMessageToWebview({ type: "action", action: "plusButtonTapped" }) await sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonTapped" })
}) })
) )
@@ -87,6 +87,12 @@ export function activate(context: vscode.ExtensionContext) {
}) })
) )
context.subscriptions.push(
vscode.commands.registerCommand("claude-dev.historyButtonTapped", () => {
sidebarProvider.postMessageToWebview({ type: "action", action: "historyButtonTapped" })
})
)
/* /*
We use the text document content provider API to show a diff view for new files/edits by creating a virtual document for the new content. We use the text document content provider API to show a diff view for new files/edits by creating a virtual document for the new content.

View File

@@ -1,9 +1,14 @@
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode" import * as vscode from "vscode"
import { ClaudeDev } from "../ClaudeDev" import { ClaudeDev } from "../ClaudeDev"
import { ApiModelId, ApiProvider } from "../shared/api" import { ApiModelId, ApiProvider } from "../shared/api"
import { ExtensionMessage } from "../shared/ExtensionMessage" import { ExtensionMessage } from "../shared/ExtensionMessage"
import { WebviewMessage } from "../shared/WebviewMessage" import { WebviewMessage } from "../shared/WebviewMessage"
import { downloadTask, getNonce, getUri, selectImages } from "../utils" import { downloadTask, getNonce, getUri, selectImages } from "../utils"
import * as path from "path"
import fs from "fs/promises"
import { HistoryItem } from "../shared/HistoryItem"
/* /*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -18,6 +23,7 @@ type GlobalStateKey =
| "maxRequestsPerTask" | "maxRequestsPerTask"
| "lastShownAnnouncementId" | "lastShownAnnouncementId"
| "customInstructions" | "customInstructions"
| "taskHistory"
export class ClaudeDevProvider implements vscode.WebviewViewProvider { export class ClaudeDevProvider implements vscode.WebviewViewProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension. public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
@@ -27,10 +33,7 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
private claudeDev?: ClaudeDev private claudeDev?: ClaudeDev
private latestAnnouncementId = "aug-15-2024" // update to some unique identifier when we add a new announcement private latestAnnouncementId = "aug-15-2024" // update to some unique identifier when we add a new announcement
constructor( constructor(readonly context: vscode.ExtensionContext, private readonly outputChannel: vscode.OutputChannel) {
private readonly context: vscode.ExtensionContext,
private readonly outputChannel: vscode.OutputChannel
) {
this.outputChannel.appendLine("ClaudeDevProvider instantiated") this.outputChannel.appendLine("ClaudeDevProvider instantiated")
} }
@@ -142,6 +145,20 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
this.claudeDev = new ClaudeDev(this, apiConfiguration, maxRequestsPerTask, customInstructions, task, images) this.claudeDev = new ClaudeDev(this, apiConfiguration, maxRequestsPerTask, customInstructions, task, images)
} }
async initClaudeDevWithHistoryItem(historyItem: HistoryItem) {
await this.clearTask()
const { maxRequestsPerTask, apiConfiguration, customInstructions } = await this.getState()
this.claudeDev = new ClaudeDev(
this,
apiConfiguration,
maxRequestsPerTask,
customInstructions,
undefined,
undefined,
historyItem
)
}
// Send any JSON serializable data to the react app // Send any JSON serializable data to the react app
async postMessageToWebview(message: ExtensionMessage) { async postMessageToWebview(message: ExtensionMessage) {
await this.view?.webview.postMessage(message) await this.view?.webview.postMessage(message)
@@ -304,13 +321,23 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("lastShownAnnouncementId", this.latestAnnouncementId) await this.updateGlobalState("lastShownAnnouncementId", this.latestAnnouncementId)
await this.postStateToWebview() await this.postStateToWebview()
break break
case "downloadTask":
downloadTask(this.claudeDev?.apiConversationHistory ?? [])
break
case "selectImages": case "selectImages":
const images = await selectImages() const images = await selectImages()
await this.postMessageToWebview({ type: "selectedImages", images }) await this.postMessageToWebview({ type: "selectedImages", images })
break break
case "exportCurrentTask":
const firstMessageTs = this.claudeDev?.claudeMessages.at(0)?.ts ?? Date.now()
downloadTask(firstMessageTs, this.claudeDev?.apiConversationHistory ?? [])
break
case "showTaskWithId":
this.showTaskWithId(message.text!)
break
case "deleteTaskWithId":
this.deleteTaskWithId(message.text!)
break
case "exportTaskWithId":
this.exportTaskWithId(message.text!)
break
// Add more switch case statements here as more webview message commands // Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js) // are created within the webview context (i.e. inside media/main.js)
} }
@@ -320,8 +347,94 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
) )
} }
// Task history
async getTaskWithId(id: string): Promise<{
historyItem: HistoryItem
taskDirPath: string
apiConversationHistoryFilePath: string
claudeMessagesFilePath: string
apiConversationHistory: Anthropic.MessageParam[]
}> {
const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const historyItem = history.find((item) => item.id === id)
if (historyItem) {
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks", id)
const apiConversationHistoryFilePath = path.join(taskDirPath, "api_conversation_history.json")
const claudeMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
const fileExists = await fs
.access(apiConversationHistoryFilePath)
.then(() => true)
.catch(() => false)
if (fileExists) {
const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8"))
return {
historyItem,
taskDirPath,
apiConversationHistoryFilePath,
claudeMessagesFilePath,
apiConversationHistory,
}
}
}
// if we tried to get a task that doesn't exist, remove it from state
await this.deleteTaskFromState(id)
throw new Error("Task not found")
}
async showTaskWithId(id: string) {
if (id !== this.claudeDev?.taskId) {
// non-current task
const { historyItem } = await this.getTaskWithId(id)
await this.initClaudeDevWithHistoryItem(historyItem) // clears existing task
}
await this.postMessageToWebview({ type: "action", action: "chatButtonTapped" })
}
async exportTaskWithId(id: string) {
const { historyItem, apiConversationHistory } = await this.getTaskWithId(id)
await downloadTask(historyItem.ts, apiConversationHistory)
}
async deleteTaskWithId(id: string) {
if (id === this.claudeDev?.taskId) {
await this.clearTask()
}
const { taskDirPath, apiConversationHistoryFilePath, claudeMessagesFilePath } = await this.getTaskWithId(id)
// Delete the task files
const apiConversationHistoryFileExists = await fs
.access(apiConversationHistoryFilePath)
.then(() => true)
.catch(() => false)
if (apiConversationHistoryFileExists) {
await fs.unlink(apiConversationHistoryFilePath)
}
const claudeMessagesFileExists = await fs
.access(claudeMessagesFilePath)
.then(() => true)
.catch(() => false)
if (claudeMessagesFileExists) {
await fs.unlink(claudeMessagesFilePath)
}
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
await this.deleteTaskFromState(id)
}
async deleteTaskFromState(id: string) {
// Remove the task from history
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const updatedTaskHistory = taskHistory.filter((task) => task.id !== id)
await this.updateGlobalState("taskHistory", updatedTaskHistory)
// Notify the webview that the task has been deleted
await this.postStateToWebview()
}
async postStateToWebview() { async postStateToWebview() {
const { apiConfiguration, maxRequestsPerTask, lastShownAnnouncementId, customInstructions } = const { apiConfiguration, maxRequestsPerTask, lastShownAnnouncementId, customInstructions, taskHistory } =
await this.getState() await this.getState()
this.postMessageToWebview({ this.postMessageToWebview({
type: "state", type: "state",
@@ -332,6 +445,7 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
customInstructions, customInstructions,
themeName: vscode.workspace.getConfiguration("workbench").get<string>("colorTheme"), themeName: vscode.workspace.getConfiguration("workbench").get<string>("colorTheme"),
claudeMessages: this.claudeDev?.claudeMessages || [], claudeMessages: this.claudeDev?.claudeMessages || [],
taskHistory: (taskHistory || []).sort((a, b) => b.ts - a.ts),
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId, shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
}, },
}) })
@@ -435,6 +549,7 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
maxRequestsPerTask, maxRequestsPerTask,
lastShownAnnouncementId, lastShownAnnouncementId,
customInstructions, customInstructions,
taskHistory,
] = await Promise.all([ ] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>, this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<ApiModelId | undefined>, this.getGlobalState("apiModelId") as Promise<ApiModelId | undefined>,
@@ -446,6 +561,7 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>, this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>, this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
this.getGlobalState("customInstructions") as Promise<string | undefined>, this.getGlobalState("customInstructions") as Promise<string | undefined>,
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
]) ])
let apiProvider: ApiProvider let apiProvider: ApiProvider
@@ -475,9 +591,22 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
maxRequestsPerTask, maxRequestsPerTask,
lastShownAnnouncementId, lastShownAnnouncementId,
customInstructions, customInstructions,
taskHistory,
} }
} }
async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[]) || []
const existingItemIndex = history.findIndex((h) => h.id === item.id)
if (existingItemIndex !== -1) {
history[existingItemIndex] = item
} else {
history.push(item)
}
await this.updateGlobalState("taskHistory", history)
return history
}
// global // global
private async updateGlobalState(key: GlobalStateKey, value: any) { private async updateGlobalState(key: GlobalStateKey, value: any) {

View File

@@ -1,12 +1,13 @@
// type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonTapped' or 'settingsButtonTapped' or 'hello' // type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonTapped' or 'settingsButtonTapped' or 'hello'
import { ApiConfiguration } from "./api" import { ApiConfiguration } from "./api"
import { HistoryItem } from "./HistoryItem"
// webview will hold state // webview will hold state
export interface ExtensionMessage { export interface ExtensionMessage {
type: "action" | "state" | "selectedImages" type: "action" | "state" | "selectedImages"
text?: string text?: string
action?: "plusButtonTapped" | "settingsButtonTapped" | "didBecomeVisible" action?: "chatButtonTapped" | "settingsButtonTapped" | "historyButtonTapped" | "didBecomeVisible"
state?: ExtensionState state?: ExtensionState
images?: string[] images?: string[]
} }
@@ -18,6 +19,7 @@ export interface ExtensionState {
customInstructions?: string customInstructions?: string
themeName?: string themeName?: string
claudeMessages: ClaudeMessage[] claudeMessages: ClaudeMessage[]
taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean shouldShowAnnouncement: boolean
} }
@@ -38,6 +40,7 @@ export type ClaudeAsk =
| "completion_result" | "completion_result"
| "tool" | "tool"
| "api_req_failed" | "api_req_failed"
| "resume_task"
export type ClaudeSay = export type ClaudeSay =
| "task" | "task"

10
src/shared/HistoryItem.ts Normal file
View File

@@ -0,0 +1,10 @@
export type HistoryItem = {
id: string
ts: number
task: string
tokensIn: number
tokensOut: number
cacheWrites?: number
cacheReads?: number
totalCost: number
}

View File

@@ -10,8 +10,11 @@ export interface WebviewMessage {
| "askResponse" | "askResponse"
| "clearTask" | "clearTask"
| "didShowAnnouncement" | "didShowAnnouncement"
| "downloadTask"
| "selectImages" | "selectImages"
| "exportCurrentTask"
| "showTaskWithId"
| "deleteTaskWithId"
| "exportTaskWithId"
text?: string text?: string
askResponse?: ClaudeAskResponse askResponse?: ClaudeAskResponse
apiConfiguration?: ApiConfiguration apiConfiguration?: ApiConfiguration

View File

@@ -1,4 +1,4 @@
import { ClaudeMessage } from "../../../src/shared/ExtensionMessage" import { ClaudeMessage } from "./ExtensionMessage"
/** /**
* Combines API request start and finish messages in an array of ClaudeMessages. * Combines API request start and finish messages in an array of ClaudeMessages.

View File

@@ -1,4 +1,4 @@
import { ClaudeMessage } from "../../../src/shared/ExtensionMessage" import { ClaudeMessage } from "./ExtensionMessage"
/** /**
* Combines sequences of command and command_output messages in an array of ClaudeMessages. * Combines sequences of command and command_output messages in an array of ClaudeMessages.

View File

@@ -1,4 +1,4 @@
import { ClaudeMessage } from "../../../src/shared/ExtensionMessage" import { ClaudeMessage } from "./ExtensionMessage"
interface ApiMetrics { interface ApiMetrics {
totalTokensIn: number totalTokensIn: number

View File

@@ -3,18 +3,19 @@ import os from "os"
import * as path from "path" import * as path from "path"
import * as vscode from "vscode" import * as vscode from "vscode"
export async function downloadTask(conversationHistory: Anthropic.MessageParam[]) { export async function downloadTask(dateTs: number, conversationHistory: Anthropic.MessageParam[]) {
// File name // File name
const date = new Date() const date = new Date(dateTs)
const month = date.toLocaleString("en-US", { month: "short" }).toLowerCase() const month = date.toLocaleString("en-US", { month: "short" }).toLowerCase()
const day = date.getDate() const day = date.getDate()
const year = date.getFullYear() const year = date.getFullYear()
let hours = date.getHours() let hours = date.getHours()
const minutes = date.getMinutes().toString().padStart(2, "0") const minutes = date.getMinutes().toString().padStart(2, "0")
const seconds = date.getSeconds().toString().padStart(2, "0")
const ampm = hours >= 12 ? "pm" : "am" const ampm = hours >= 12 ? "pm" : "am"
hours = hours % 12 hours = hours % 12
hours = hours ? hours : 12 // the hour '0' should be '12' hours = hours ? hours : 12 // the hour '0' should be '12'
const fileName = `claude_dev_task_${month}-${day}-${year}_${hours}-${minutes}-${ampm}.md` const fileName = `claude_dev_task_${month}-${day}-${year}_${hours}-${minutes}-${seconds}-${ampm}.md`
// Generate markdown // Generate markdown
const markdownContent = conversationHistory const markdownContent = conversationHistory

View File

@@ -8,6 +8,8 @@ import ChatView from "./components/ChatView"
import SettingsView from "./components/SettingsView" import SettingsView from "./components/SettingsView"
import WelcomeView from "./components/WelcomeView" import WelcomeView from "./components/WelcomeView"
import { vscode } from "./utils/vscode" import { vscode } from "./utils/vscode"
import HistoryView from "./components/HistoryView"
import { HistoryItem } from "../../src/shared/HistoryItem"
/* /*
The contents of webviews however are created when the webview becomes visible and destroyed when the webview is moved into the background. Any state inside the webview will be lost when the webview is moved to a background tab. The contents of webviews however are created when the webview becomes visible and destroyed when the webview is moved into the background. Any state inside the webview will be lost when the webview is moved to a background tab.
@@ -18,6 +20,7 @@ The best way to solve this is to make your webview stateless. Use message passin
const App: React.FC = () => { const App: React.FC = () => {
const [didHydrateState, setDidHydrateState] = useState(false) const [didHydrateState, setDidHydrateState] = useState(false)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showWelcome, setShowWelcome] = useState<boolean>(false) const [showWelcome, setShowWelcome] = useState<boolean>(false)
const [version, setVersion] = useState<string>("") const [version, setVersion] = useState<string>("")
const [apiConfiguration, setApiConfiguration] = useState<ApiConfiguration | undefined>(undefined) const [apiConfiguration, setApiConfiguration] = useState<ApiConfiguration | undefined>(undefined)
@@ -25,6 +28,7 @@ const App: React.FC = () => {
const [customInstructions, setCustomInstructions] = useState<string>("") const [customInstructions, setCustomInstructions] = useState<string>("")
const [vscodeThemeName, setVscodeThemeName] = useState<string | undefined>(undefined) const [vscodeThemeName, setVscodeThemeName] = useState<string | undefined>(undefined)
const [claudeMessages, setClaudeMessages] = useState<ClaudeMessage[]>([]) const [claudeMessages, setClaudeMessages] = useState<ClaudeMessage[]>([])
const [taskHistory, setTaskHistory] = useState<HistoryItem[]>([])
const [showAnnouncement, setShowAnnouncement] = useState(false) const [showAnnouncement, setShowAnnouncement] = useState(false)
useEffect(() => { useEffect(() => {
@@ -48,6 +52,7 @@ const App: React.FC = () => {
setCustomInstructions(message.state!.customInstructions || "") setCustomInstructions(message.state!.customInstructions || "")
setVscodeThemeName(message.state!.themeName) setVscodeThemeName(message.state!.themeName)
setClaudeMessages(message.state!.claudeMessages) setClaudeMessages(message.state!.claudeMessages)
setTaskHistory(message.state!.taskHistory)
// don't update showAnnouncement to false if shouldShowAnnouncement is false // don't update showAnnouncement to false if shouldShowAnnouncement is false
if (message.state!.shouldShowAnnouncement) { if (message.state!.shouldShowAnnouncement) {
setShowAnnouncement(true) setShowAnnouncement(true)
@@ -59,9 +64,15 @@ const App: React.FC = () => {
switch (message.action!) { switch (message.action!) {
case "settingsButtonTapped": case "settingsButtonTapped":
setShowSettings(true) setShowSettings(true)
setShowHistory(false)
break break
case "plusButtonTapped": case "historyButtonTapped":
setShowSettings(false) setShowSettings(false)
setShowHistory(true)
break
case "chatButtonTapped":
setShowSettings(false)
setShowHistory(false)
break break
} }
break break
@@ -97,11 +108,17 @@ const App: React.FC = () => {
onDone={() => setShowSettings(false)} onDone={() => setShowSettings(false)}
/> />
)} )}
{showHistory && <HistoryView taskHistory={taskHistory} onDone={() => setShowHistory(false)} />}
{/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */} {/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */}
<ChatView <ChatView
version={version} version={version}
messages={claudeMessages} messages={claudeMessages}
isHidden={showSettings} taskHistory={taskHistory}
showHistoryView={() => {
setShowSettings(false)
setShowHistory(true)
}}
isHidden={showSettings || showHistory}
vscodeThemeName={vscodeThemeName} vscodeThemeName={vscodeThemeName}
showAnnouncement={showAnnouncement} showAnnouncement={showAnnouncement}
selectedModelSupportsImages={selectedModelInfo.supportsImages} selectedModelSupportsImages={selectedModelInfo.supportsImages}

View File

@@ -3,7 +3,7 @@ import React from "react"
import Markdown from "react-markdown" import Markdown from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "../../../src/shared/ExtensionMessage" import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "../../../src/shared/ExtensionMessage"
import { COMMAND_OUTPUT_STRING } from "../utils/combineCommandSequences" import { COMMAND_OUTPUT_STRING } from "../../../src/shared/combineCommandSequences"
import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme" import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme"
import CodeBlock from "./CodeBlock/CodeBlock" import CodeBlock from "./CodeBlock/CodeBlock"
import Thumbnails from "./Thumbnails" import Thumbnails from "./Thumbnails"

View File

@@ -5,25 +5,29 @@ import DynamicTextArea from "react-textarea-autosize"
import { useEvent, useMount } from "react-use" import { useEvent, useMount } from "react-use"
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
import { ClaudeAsk, ClaudeMessage, ExtensionMessage } from "../../../src/shared/ExtensionMessage" import { ClaudeAsk, ClaudeMessage, ExtensionMessage } from "../../../src/shared/ExtensionMessage"
import { combineApiRequests } from "../utils/combineApiRequests" import { getApiMetrics } from "../../../src/shared/getApiMetrics"
import { combineCommandSequences } from "../utils/combineCommandSequences" import { combineApiRequests } from "../../../src/shared/combineApiRequests"
import { getApiMetrics } from "../utils/getApiMetrics" import { combineCommandSequences } from "../../../src/shared/combineCommandSequences"
import { getSyntaxHighlighterStyleFromTheme } from "../utils/getSyntaxHighlighterStyleFromTheme" import { getSyntaxHighlighterStyleFromTheme } from "../utils/getSyntaxHighlighterStyleFromTheme"
import { vscode } from "../utils/vscode" import { vscode } from "../utils/vscode"
import Announcement from "./Announcement" import Announcement from "./Announcement"
import ChatRow from "./ChatRow" import ChatRow from "./ChatRow"
import HistoryPreview from "./HistoryPreview"
import TaskHeader from "./TaskHeader" import TaskHeader from "./TaskHeader"
import Thumbnails from "./Thumbnails" import Thumbnails from "./Thumbnails"
import { HistoryItem } from "../../../src/shared/HistoryItem"
interface ChatViewProps { interface ChatViewProps {
version: string version: string
messages: ClaudeMessage[] messages: ClaudeMessage[]
taskHistory: HistoryItem[]
isHidden: boolean isHidden: boolean
vscodeThemeName?: string vscodeThemeName?: string
showAnnouncement: boolean showAnnouncement: boolean
selectedModelSupportsImages: boolean selectedModelSupportsImages: boolean
selectedModelSupportsPromptCache: boolean selectedModelSupportsPromptCache: boolean
hideAnnouncement: () => void hideAnnouncement: () => void
showHistoryView: () => void
} }
const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
@@ -31,14 +35,16 @@ const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
const ChatView = ({ const ChatView = ({
version, version,
messages, messages,
taskHistory,
isHidden, isHidden,
vscodeThemeName, vscodeThemeName,
showAnnouncement, showAnnouncement,
selectedModelSupportsImages, selectedModelSupportsImages,
selectedModelSupportsPromptCache, selectedModelSupportsPromptCache,
hideAnnouncement, hideAnnouncement,
showHistoryView,
}: ChatViewProps) => { }: ChatViewProps) => {
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined //const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = messages.length > 0 ? messages[0] : undefined // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see ClaudeDev.abort) const task = messages.length > 0 ? messages[0] : undefined // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see ClaudeDev.abort)
const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages]) const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
// has to be after api_req_finished are all reduced into api_req_started messages // has to be after api_req_finished are all reduced into api_req_started messages
@@ -137,6 +143,13 @@ const ChatView = ({
setPrimaryButtonText("Start New Task") setPrimaryButtonText("Start New Task")
setSecondaryButtonText(undefined) setSecondaryButtonText(undefined)
break break
case "resume_task":
setTextAreaDisabled(false)
setClaudeAsk("resume_task")
setEnableButtons(true)
setPrimaryButtonText("Resume Task")
setSecondaryButtonText(undefined)
break
} }
break break
case "say": case "say":
@@ -199,6 +212,7 @@ const ChatView = ({
case "command": // user can provide feedback to a tool or command use case "command": // user can provide feedback to a tool or command use
case "command_output": // user can send input to command stdin case "command_output": // user can send input to command stdin
case "completion_result": // if this happens then the user has feedback for the completion result case "completion_result": // if this happens then the user has feedback for the completion result
case "resume_task":
vscode.postMessage({ vscode.postMessage({
type: "askResponse", type: "askResponse",
askResponse: "messageResponse", askResponse: "messageResponse",
@@ -229,6 +243,7 @@ const ChatView = ({
case "command": case "command":
case "command_output": case "command_output":
case "tool": case "tool":
case "resume_task":
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonTapped" }) vscode.postMessage({ type: "askResponse", askResponse: "yesButtonTapped" })
break break
case "completion_result": case "completion_result":
@@ -392,6 +407,8 @@ const ChatView = ({
break break
case "api_req_failed": // this message is used to update the latest api_req_started that the request failed case "api_req_failed": // this message is used to update the latest api_req_started that the request failed
return false return false
case "resume_task":
return false
} }
switch (message.say) { switch (message.say) {
case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
@@ -460,7 +477,7 @@ const ChatView = ({
) : ( ) : (
<> <>
{showAnnouncement && <Announcement version={version} hideAnnouncement={hideAnnouncement} />} {showAnnouncement && <Announcement version={version} hideAnnouncement={hideAnnouncement} />}
<div style={{ padding: "0 20px" }}> <div style={{ padding: "0 20px", flexGrow: taskHistory.length > 0 ? undefined : 1 }}>
<h2>What can I do for you?</h2> <h2>What can I do for you?</h2>
<p> <p>
Thanks to{" "} Thanks to{" "}
@@ -474,8 +491,13 @@ const ChatView = ({
permission), I can assist you in ways that go beyond simple code completion or tech support. permission), I can assist you in ways that go beyond simple code completion or tech support.
</p> </p>
</div> </div>
{taskHistory.length > 0 && (
<HistoryPreview taskHistory={taskHistory} showHistoryView={showHistoryView} />
)}
</> </>
)} )}
{task && (
<>
<Virtuoso <Virtuoso
ref={virtuosoRef} ref={virtuosoRef}
className="scrollable" className="scrollable"
@@ -532,6 +554,9 @@ const ChatView = ({
</VSCodeButton> </VSCodeButton>
)} )}
</div> </div>
</>
)}
<div <div
style={{ style={{
padding: "10px 15px", padding: "10px 15px",

View File

@@ -0,0 +1,141 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "../utils/vscode"
import { HistoryItem } from "../../../src/shared/HistoryItem"
type HistoryPreviewProps = {
taskHistory: HistoryItem[]
showHistoryView: () => void
}
const HistoryPreview = ({ taskHistory, showHistoryView }: HistoryPreviewProps) => {
const handleHistorySelect = (id: string) => {
vscode.postMessage({ type: "showTaskWithId", text: id })
}
const formatDate = (timestamp: number) => {
const date = new Date(timestamp)
return date
.toLocaleString("en-US", {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
.replace(", ", " ")
.replace(" at", ",")
.toUpperCase()
}
return (
<div style={{ flexGrow: 1, overflowY: "auto" }}>
<style>
{`
.history-preview-item {
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 50%, transparent);
border-radius: 4px;
position: relative;
overflow: hidden;
opacity: 0.8;
cursor: pointer;
margin-bottom: 12px;
}
.history-preview-item:hover {
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 100%, transparent);
opacity: 1;
pointer-events: auto;
}
`}
</style>
<div
style={{
color: "var(--vscode-descriptionForeground)",
margin: "10px 20px 10px 20px",
display: "flex",
alignItems: "center",
}}>
<span
className="codicon codicon-comment-discussion"
style={{ marginRight: "4px", transform: "scale(0.9)" }}></span>
<span
style={{
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
Recent Tasks
</span>
</div>
<div style={{ padding: "0px 20px 0 20px" }}>
{taskHistory.slice(0, 3).map((item) => (
<div key={item.id} className="history-preview-item" onClick={() => handleHistorySelect(item.id)}>
<div style={{ padding: "12px" }}>
<div style={{ marginBottom: "8px" }}>
<span
style={{
color: "var(--vscode-descriptionForeground)",
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
{formatDate(item.ts)}
</span>
</div>
<div
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-descriptionForeground)",
marginBottom: "8px",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
{item.task}
</div>
<div style={{ fontSize: "0.85em", color: "var(--vscode-descriptionForeground)" }}>
<span>
Tokens: {item.tokensIn.toLocaleString()} {item.tokensOut.toLocaleString()}
</span>
{" • "}
{item.cacheWrites && item.cacheReads && (
<>
<span>
Cache: +{item.cacheWrites.toLocaleString()} {" "}
{item.cacheReads.toLocaleString()}
</span>
{" • "}
</>
)}
<span>API Cost: ${item.totalCost.toFixed(4)}</span>
</div>
</div>
</div>
))}
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<VSCodeButton
appearance="icon"
onClick={() => showHistoryView()}
style={{
opacity: 0.9,
}}>
<div
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-descriptionForeground)",
}}>
View all history
</div>
</VSCodeButton>
</div>
</div>
</div>
)
}
export default HistoryPreview

View File

@@ -0,0 +1,295 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "../utils/vscode"
import { HistoryItem } from "../../../src/shared/HistoryItem"
type HistoryViewProps = {
taskHistory: HistoryItem[]
onDone: () => void
}
const HistoryView = ({ taskHistory, onDone }: HistoryViewProps) => {
const handleHistorySelect = (id: string) => {
vscode.postMessage({ type: "showTaskWithId", text: id })
}
const handleDeleteHistoryItem = (id: string) => {
vscode.postMessage({ type: "deleteTaskWithId", text: id })
}
const handleExportMd = (id: string) => {
vscode.postMessage({ type: "exportTaskWithId", text: id })
}
const formatDate = (timestamp: number) => {
const date = new Date(timestamp)
return date
.toLocaleString("en-US", {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
.replace(", ", " ")
.replace(" at", ",")
.toUpperCase()
}
return (
<>
<style>
{`
.history-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.delete-button {
opacity: 0;
pointer-events: none;
}
.history-item:hover .delete-button {
opacity: 1;
pointer-events: auto;
}
`}
</style>
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 16px 10px 20px",
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>History</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
<div style={{ flexGrow: 1, overflowY: "auto", margin: 0 }}>
{taskHistory.length === 0 && (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100%",
fontStyle: "italic",
color: "var(--vscode-descriptionForeground)",
textAlign: "center",
padding: "0px 10px",
}}>
<span
className="codicon codicon-archive"
style={{ fontSize: "50px", marginBottom: "15px" }}></span>
<div>
No history found,
<br />
start a new task to see it here...
</div>
</div>
)}
{taskHistory.map((item, index) => (
<div
key={item.id}
className="history-item"
style={{
cursor: "pointer",
borderBottom:
index < taskHistory.length - 1 ? "1px solid var(--vscode-panel-border)" : "none",
}}
onClick={() => handleHistorySelect(item.id)}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
padding: "12px 20px",
position: "relative",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<span
style={{
color: "var(--vscode-descriptionForeground)",
fontWeight: 500,
fontSize: "0.85em",
textTransform: "uppercase",
}}>
{formatDate(item.ts)}
</span>
<VSCodeButton
appearance="icon"
onClick={(e) => {
e.stopPropagation()
handleDeleteHistoryItem(item.id)
}}
className="delete-button">
<span className="codicon codicon-trash"></span>
</VSCodeButton>
</div>
<div
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-foreground)",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
{item.task}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
flexWrap: "wrap",
}}>
<span
style={{
fontWeight: 500,
color: "var(--vscode-descriptionForeground)",
}}>
Tokens:
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: "3px",
color: "var(--vscode-descriptionForeground)",
}}>
<i
className="codicon codicon-arrow-up"
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-2px",
}}
/>
{item.tokensIn.toLocaleString()}
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: "3px",
color: "var(--vscode-descriptionForeground)",
}}>
<i
className="codicon codicon-arrow-down"
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-2px",
}}
/>
{item.tokensOut.toLocaleString()}
</span>
</div>
{item.cacheWrites && item.cacheReads && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
flexWrap: "wrap",
}}>
<span
style={{
fontWeight: 500,
color: "var(--vscode-descriptionForeground)",
}}>
Cache:
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: "3px",
color: "var(--vscode-descriptionForeground)",
}}>
<i
className="codicon codicon-database"
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-1px",
}}
/>
+{item.cacheWrites.toLocaleString()}
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: "3px",
color: "var(--vscode-descriptionForeground)",
}}>
<i
className="codicon codicon-arrow-right"
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: 0 }}
/>
{item.cacheReads.toLocaleString()}
</span>
</div>
)}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: -2,
}}>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<span
style={{
fontWeight: 500,
color: "var(--vscode-descriptionForeground)",
}}>
API Cost:
</span>
<span style={{ color: "var(--vscode-descriptionForeground)" }}>
${item.totalCost.toFixed(4)}
</span>
</div>
<VSCodeButton
appearance="icon"
onClick={(e) => {
e.stopPropagation()
handleExportMd(item.id)
}}>
<div style={{ fontSize: "11px", fontWeight: 500, opacity: 1 }}>
EXPORT .MD
</div>
</VSCodeButton>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</>
)
}
export default HistoryView

View File

@@ -0,0 +1,167 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import React, { useState } from "react"
export const TAB_NAVBAR_HEIGHT = 24
const BUTTON_MARGIN_RIGHT = "3px"
const LAST_BUTTON_MARGIN_RIGHT = "13px"
type TabNavbarProps = {
onPlusClick: () => void
onHistoryClick: () => void
onSettingsClick: () => void
}
type TooltipProps = {
text: string
isVisible: boolean
position: { x: number; y: number }
align?: "left" | "center" | "right"
}
const Tooltip: React.FC<TooltipProps> = ({ text, isVisible, position, align = "center" }) => {
let leftPosition = position.x
let triangleStyle: React.CSSProperties = {
left: "50%",
marginLeft: "-5px",
}
if (align === "right") {
leftPosition = position.x - 10 // Adjust this value as needed
triangleStyle = {
right: "10px", // Adjust this value to match the tooltip's right padding
marginLeft: "0",
}
} else if (align === "left") {
leftPosition = position.x + 10 // Adjust this value as needed
triangleStyle = {
left: "10px", // Adjust this value to match the tooltip's left padding
marginLeft: "0",
}
}
return (
<div
style={{
position: "fixed",
top: `${position.y}px`,
left: align === "center" ? leftPosition + "px" : "auto",
right: align === "right" ? "10px" : "auto", // Ensure 10px from screen edge
transform: align === "center" ? "translateX(-50%)" : "none",
opacity: isVisible ? 1 : 0,
visibility: isVisible ? "visible" : "hidden",
transition: "opacity 0.1s ease-out 0.1s, visibility 0.1s ease-out 0.1s",
backgroundColor: "var(--vscode-editorHoverWidget-background)",
color: "var(--vscode-editorHoverWidget-foreground)",
padding: "4px 8px",
borderRadius: "3px",
fontSize: "12px",
pointerEvents: "none",
zIndex: 1000,
boxShadow: "0 2px 8px var(--vscode-widget-shadow)",
border: "1px solid var(--vscode-editorHoverWidget-border)",
textAlign: "center",
whiteSpace: "nowrap",
}}>
<div
style={{
position: "absolute",
top: "-5px",
...triangleStyle,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderBottom: "5px solid var(--vscode-editorHoverWidget-border)",
}}
/>
<div
style={{
position: "absolute",
top: "-4px",
...triangleStyle,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderBottom: "5px solid var(--vscode-editorHoverWidget-background)",
}}
/>
{text}
</div>
)
}
const TabNavbar = ({ onPlusClick, onHistoryClick, onSettingsClick }: TabNavbarProps) => {
const [tooltip, setTooltip] = useState<TooltipProps>({
text: "",
isVisible: false,
position: { x: 0, y: 0 },
align: "center",
})
const showTooltip = (text: string, event: React.MouseEvent, align: "left" | "center" | "right" = "center") => {
const rect = event.currentTarget.getBoundingClientRect()
setTooltip({
text,
isVisible: true,
position: { x: rect.left + rect.width / 2, y: rect.bottom + 7 },
align,
})
}
const hideTooltip = () => {
setTooltip((prev) => ({ ...prev, isVisible: false }))
}
const buttonStyle = {
marginRight: BUTTON_MARGIN_RIGHT,
}
const lastButtonStyle = {
...buttonStyle,
marginRight: LAST_BUTTON_MARGIN_RIGHT,
}
return (
<>
<div
style={{
position: "absolute",
top: 4,
right: 0,
left: 0,
height: TAB_NAVBAR_HEIGHT,
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
}}>
<VSCodeButton
appearance="icon"
onClick={onPlusClick}
style={buttonStyle}
onMouseEnter={(e) => showTooltip("New Chat", e, "center")}
onMouseLeave={hideTooltip}
onMouseMove={(e) => showTooltip("New Chat", e, "center")}>
<span className="codicon codicon-add"></span>
</VSCodeButton>
<VSCodeButton
appearance="icon"
onClick={onHistoryClick}
style={buttonStyle}
onMouseEnter={(e) => showTooltip("History", e, "center")}
onMouseLeave={hideTooltip}
onMouseMove={(e) => showTooltip("History", e, "center")}>
<span className="codicon codicon-history"></span>
</VSCodeButton>
<VSCodeButton
appearance="icon"
onClick={onSettingsClick}
style={lastButtonStyle}
onMouseEnter={(e) => showTooltip("Settings", e, "right")}
onMouseLeave={hideTooltip}
onMouseMove={(e) => showTooltip("Settings", e, "right")}>
<span className="codicon codicon-settings-gear"></span>
</VSCodeButton>
</div>
<Tooltip {...tooltip} />
</>
)
}
export default TabNavbar

View File

@@ -92,11 +92,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
const toggleExpand = () => setIsExpanded(!isExpanded) const toggleExpand = () => setIsExpanded(!isExpanded)
const handleDownload = () => { const handleDownload = () => {
vscode.postMessage({ type: "downloadTask" }) vscode.postMessage({ type: "exportCurrentTask" })
} }
return ( return (
<div style={{ padding: "15px 15px 10px 15px" }}> <div style={{ padding: "10px 13px 10px 13px" }}>
<div <div
style={{ style={{
backgroundColor: "var(--vscode-badge-background)", backgroundColor: "var(--vscode-badge-background)",
@@ -118,7 +118,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<VSCodeButton <VSCodeButton
appearance="icon" appearance="icon"
onClick={onClose} onClick={onClose}
style={{ marginTop: "-5px", marginRight: "-5px" }}> style={{ marginTop: "-6px", marginRight: "-4px" }}>
<span className="codicon codicon-close"></span> <span className="codicon codicon-close"></span>
</VSCodeButton> </VSCodeButton>
</div> </div>