Skip to content

Commit 1d8e86a

Browse files
committed
feat(content): generalize custom instructions and enhance refresh UX
- Refactor custom instructions management to a generic 'content' system, introducing `GlobalContentIds`. - Implement new `openContent`, `updateContent`, and `refreshContent` methods in `ClineProvider` to handle various content types. - Update webview message interfaces (`WebviewMessage`, `ExtensionMessage`) to support content IDs and a new `contentRefreshed` event. - Enhance the custom instructions UI in the webview with a dedicated `useRefreshableContent` hook. - Add visual feedback for content refresh operations, including a loading spinner and a success message.
1 parent 86d8d05 commit 1d8e86a

File tree

8 files changed

+147
-45
lines changed

8 files changed

+147
-45
lines changed

src/core/webview/ClineProvider.ts

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { Package } from "../../shared/package"
3737
import { findLast } from "../../shared/array"
3838
import { supportPrompt } from "../../shared/support-prompt"
3939
import { GlobalFileNames } from "../../shared/globalFileNames"
40+
import { GlobalContentIds } from "../../shared/globalContentIds"
4041
import { ExtensionMessage } from "../../shared/ExtensionMessage"
4142
import { Mode, defaultModeSlug } from "../../shared/modes"
4243
import { experimentDefault, experiments, EXPERIMENT_IDS } from "../../shared/experiments"
@@ -996,45 +997,75 @@ export class ClineProvider
996997
return getSettingsDirectoryPath(globalStoragePath)
997998
}
998999

999-
// Custom Instructions
1000+
private async getContentPath(contentId: string): Promise<string | undefined> {
1001+
const settingsDir = await this.ensureSettingsDirectoryExists()
1002+
switch (contentId) {
1003+
case GlobalContentIds.customInstructions:
1004+
return path.join(settingsDir, GlobalFileNames.customInstructions)
1005+
default:
1006+
this.log(`Unknown contentId: ${contentId}`)
1007+
return undefined
1008+
}
1009+
}
1010+
1011+
// Content
1012+
1013+
async openContent(contentId: string): Promise<void> {
1014+
const filePath = await this.getContentPath(contentId)
1015+
1016+
if (!filePath) {
1017+
this.log(`File path could not be determined for contentId: ${contentId}`)
1018+
return
1019+
}
10001020

1001-
async updateCustomInstructions(instructions?: string) {
1002-
const settingsDirPath = await this.ensureSettingsDirectoryExists()
1003-
const customInstructionsFilePath = path.join(settingsDirPath, GlobalFileNames.customInstructions)
1021+
const uri = vscode.Uri.file(filePath)
1022+
await vscode.commands.executeCommand("vscode.open", uri, {
1023+
preview: false,
1024+
preserveFocus: true,
1025+
})
1026+
await vscode.commands.executeCommand("workbench.action.files.revert")
1027+
}
10041028

1005-
if (instructions && instructions.trim()) {
1006-
await fs.writeFile(customInstructionsFilePath, instructions.trim(), "utf-8")
1029+
async refreshContent(contentId: string): Promise<void> {
1030+
const content = await this.readContent(contentId)
1031+
await this.updateContent(contentId, content)
1032+
this.postMessageToWebview({
1033+
type: "contentRefreshed",
1034+
contentId: contentId,
1035+
success: true, // Assuming success for now
1036+
})
1037+
}
1038+
1039+
async updateContent(contentId: string, content?: string) {
1040+
const filePath = await this.getContentPath(contentId)
1041+
1042+
if (!filePath) {
1043+
this.log(`File path could not be determined for contentId: ${contentId}`)
1044+
return
1045+
}
1046+
1047+
if (content && content.trim()) {
1048+
await fs.writeFile(filePath, content.trim(), "utf-8")
1049+
this.log(`Updated content file: ${filePath}`)
10071050
} else {
1008-
// If instructions are empty or undefined, delete the file if it exists
10091051
try {
1010-
await fs.unlink(customInstructionsFilePath)
1052+
await fs.unlink(filePath)
1053+
this.log(`Deleted content file: ${filePath}`)
10111054
} catch (error) {
1012-
// Ignore if file doesn't exist
1013-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1055+
if (error.code !== "ENOENT") {
1056+
this.log(`Error deleting content file: ${error.message}`)
10141057
throw error
10151058
}
10161059
}
10171060
}
1061+
// Update the webview state
10181062
await this.postStateToWebview()
10191063
}
10201064

1021-
async openCustomInstructionsFile(): Promise<void> {
1022-
const settingsDirPath = await this.ensureSettingsDirectoryExists()
1023-
const customInstructionsFilePath = path.join(settingsDirPath, GlobalFileNames.customInstructions)
1024-
const fileUri = vscode.Uri.file(customInstructionsFilePath)
1025-
await vscode.commands.executeCommand("vscode.open", fileUri, { preview: false, preserveFocus: true })
1026-
await vscode.commands.executeCommand("workbench.action.files.revert", fileUri)
1027-
}
1028-
1029-
async refreshCustomInstructions(): Promise<void> {
1030-
const content = await this.readCustomInstructionsFromFile()
1031-
await this.updateCustomInstructions(content)
1032-
}
1065+
private async readContent(contentId: string): Promise<string> {
1066+
const filePath = await this.getContentPath(contentId)
10331067

1034-
private async readCustomInstructionsFromFile(): Promise<string | undefined> {
1035-
const settingsDirPath = await this.ensureSettingsDirectoryExists()
1036-
const customInstructionsFilePath = path.join(settingsDirPath, GlobalFileNames.customInstructions)
1037-
return await safeReadFile(customInstructionsFilePath)
1068+
return filePath ? ((await safeReadFile(filePath)) ?? "") : ""
10381069
}
10391070

10401071
// MCP
@@ -1511,7 +1542,7 @@ export class ClineProvider
15111542
return {
15121543
apiConfiguration: providerSettings,
15131544
lastShownAnnouncementId: stateValues.lastShownAnnouncementId,
1514-
customInstructions: await this.readCustomInstructionsFromFile(),
1545+
customInstructions: await this.readContent(GlobalContentIds.customInstructions),
15151546
apiModelId: stateValues.apiModelId,
15161547
alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false,
15171548
alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false,

src/core/webview/webviewMessageHandler.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ import { RouterName, toRouterName, ModelRecord } from "../../shared/api"
1414
import { supportPrompt } from "../../shared/support-prompt"
1515

1616
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
17+
import { GlobalContentIds } from "../../shared/globalContentIds"
1718
import { checkExistKey } from "../../shared/checkExistApiConfig"
1819
import { experimentDefault } from "../../shared/experiments"
19-
import { GlobalFileNames } from "../../shared/globalFileNames"
2020
import { Terminal } from "../../integrations/terminal/Terminal"
2121
import { openFile, openImage } from "../../integrations/misc/open-file"
2222
import { selectImages } from "../../integrations/misc/process-images"
@@ -131,13 +131,23 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
131131
await provider.initClineWithTask(message.text, message.images)
132132
break
133133
case "customInstructions":
134-
await provider.updateCustomInstructions(message.text)
134+
await provider.updateContent(GlobalContentIds.customInstructions, message.text)
135135
break
136-
case "openCustomInstructionsFile":
137-
await provider.openCustomInstructionsFile()
136+
case "openContent":
137+
if (message.contentId) {
138+
// Check for contentId instead of filePath
139+
await provider.openContent(message.contentId)
140+
} else {
141+
console.error("openContent message missing contentId")
142+
}
138143
break
139-
case "refreshCustomInstructions":
140-
await provider.refreshCustomInstructions()
144+
case "refreshContent":
145+
if (message.contentId) {
146+
await provider.refreshContent(message.contentId)
147+
} else {
148+
// Handle error or log if contentId is missing
149+
console.error("refreshContent message missing contentId")
150+
}
141151
break
142152
case "alwaysAllowReadOnly":
143153
await updateGlobalState("alwaysAllowReadOnly", message.bool ?? undefined)

src/shared/ExtensionMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export interface ExtensionMessage {
7373
| "indexingStatusUpdate"
7474
| "indexCleared"
7575
| "codebaseIndexConfig"
76+
| "contentRefreshed"
7677
text?: string
7778
action?:
7879
| "chatButtonClicked"
@@ -105,6 +106,7 @@ export interface ExtensionMessage {
105106
customMode?: ModeConfig
106107
slug?: string
107108
success?: boolean
109+
contentId?: string
108110
values?: Record<string, any>
109111
requestId?: string
110112
promptText?: string

src/shared/WebviewMessage.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export interface WebviewMessage {
5151
| "openImage"
5252
| "openFile"
5353
| "openMention"
54+
| "openContent"
5455
| "cancelTask"
5556
| "updateVSCodeSetting"
5657
| "getVSCodeSetting"
@@ -118,8 +119,7 @@ export interface WebviewMessage {
118119
| "deleteCustomMode"
119120
| "setopenAiCustomModelInfo"
120121
| "openCustomModesSettings"
121-
| "openCustomInstructionsFile"
122-
| "refreshCustomInstructions"
122+
| "refreshContent"
123123
| "checkpointDiff"
124124
| "checkpointRestore"
125125
| "deleteMcpServer"
@@ -172,6 +172,7 @@ export interface WebviewMessage {
172172
modeConfig?: ModeConfig
173173
timeout?: number
174174
payload?: WebViewMessagePayload
175+
contentId?: string
175176
source?: "global" | "project"
176177
requestId?: string
177178
ids?: string[]

src/shared/globalContentIds.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const GlobalContentIds = {
2+
customInstructions: "customInstructions" as const,
3+
}

webview-ui/src/components/modes/ModesView.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import {
4444
CommandGroup,
4545
Input,
4646
} from "@src/components/ui"
47+
import { GlobalContentIds } from "../../../../src/shared/globalContentIds"
48+
import { useRefreshableContent } from "../../hooks/useRefreshableContent"
4749

4850
// Get all available groups that should show in prompts view
4951
const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable)
@@ -79,6 +81,10 @@ const ModesView = ({ onDone }: ModesViewProps) => {
7981
// 3. Still sending the mode change to the backend for persistence
8082
const [visualMode, setVisualMode] = useState(mode)
8183

84+
const { isRefreshing, showRefreshSuccess, refreshContent } = useRefreshableContent(
85+
GlobalContentIds.customInstructions,
86+
)
87+
8288
// Memoize modes to preserve array order
8389
const modes = useMemo(() => getAllModes(customModes), [customModes])
8490

@@ -1045,7 +1051,8 @@ const ModesView = ({ onDone }: ModesViewProps) => {
10451051
size="icon"
10461052
onClick={() => {
10471053
vscode.postMessage({
1048-
type: "openCustomInstructionsFile",
1054+
type: "openContent",
1055+
contentId: GlobalContentIds.customInstructions,
10491056
})
10501057
}}
10511058
title={t("prompts:globalCustomInstructions.openFile")}>
@@ -1054,17 +1061,19 @@ const ModesView = ({ onDone }: ModesViewProps) => {
10541061
<Button
10551062
variant="ghost"
10561063
size="icon"
1057-
onClick={() => {
1058-
vscode.postMessage({
1059-
type: "refreshCustomInstructions",
1060-
})
1061-
}}
1062-
title={t("prompts:globalCustomInstructions.refreshFile")}>
1063-
<span className="codicon codicon-refresh"></span>
1064+
onClick={refreshContent}
1065+
title={t("prompts:globalCustomInstructions.refreshFile")}
1066+
disabled={isRefreshing}>
1067+
<span
1068+
className={`codicon codicon-${isRefreshing ? "loading codicon-modifier-spin" : "refresh"}`}></span>
10641069
</Button>
10651070
</div>
10661071
</div>
1067-
1072+
{showRefreshSuccess && (
1073+
<div className="text-xs text-vscode-textLink-foreground mb-2">
1074+
{t("prompts:globalCustomInstructions.refreshSuccess")}
1075+
</div>
1076+
)}
10681077
<div className="text-sm text-vscode-descriptionForeground mb-2">
10691078
<Trans i18nKey="prompts:globalCustomInstructions.description">
10701079
<VSCodeLink
@@ -1084,7 +1093,7 @@ const ModesView = ({ onDone }: ModesViewProps) => {
10841093
((e as any).target as HTMLTextAreaElement).value
10851094
setCustomInstructions(value || undefined)
10861095
vscode.postMessage({
1087-
type: "customInstructions",
1096+
type: GlobalContentIds.customInstructions,
10881097
text: value.trim() || undefined,
10891098
})
10901099
}}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useState, useEffect, useCallback } from "react"
2+
import { vscode } from "../utils/vscode"
3+
import type { ExtensionMessage } from "../../../src/shared/ExtensionMessage"
4+
5+
interface UseRefreshableContentResult {
6+
isRefreshing: boolean
7+
showRefreshSuccess: boolean
8+
refreshContent: () => void
9+
}
10+
11+
export function useRefreshableContent(contentId: string): UseRefreshableContentResult {
12+
const [isRefreshing, setIsRefreshing] = useState(false)
13+
const [showRefreshSuccess, setShowRefreshSuccess] = useState(false)
14+
15+
useEffect(() => {
16+
const handleMessage = (event: MessageEvent<ExtensionMessage>) => {
17+
const message = event.data
18+
if (message.type === "contentRefreshed" && message.contentId === contentId) {
19+
setIsRefreshing(false)
20+
if (message.success) {
21+
setShowRefreshSuccess(true)
22+
const timer = setTimeout(() => {
23+
setShowRefreshSuccess(false)
24+
}, 3000)
25+
return () => clearTimeout(timer)
26+
}
27+
}
28+
}
29+
30+
window.addEventListener("message", handleMessage)
31+
return () => {
32+
window.removeEventListener("message", handleMessage)
33+
}
34+
}, [contentId])
35+
36+
const refreshContent = useCallback(() => {
37+
setIsRefreshing(true)
38+
vscode.postMessage({
39+
type: "refreshContent",
40+
contentId,
41+
})
42+
}, [contentId])
43+
44+
return { isRefreshing, showRefreshSuccess, refreshContent }
45+
}

webview-ui/src/i18n/locales/en/prompts.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"description": "These instructions apply to all modes. They provide a base set of behaviors that can be enhanced by mode-specific instructions below. <0>Learn more</0>",
5151
"openFile": "Open custom instructions file",
5252
"refreshFile": "Refresh custom instructions from file",
53+
"refreshSuccess": "Custom instructions refreshed!",
5354
"loadFromFile": "Instructions can also be loaded from the <span>.roo/rules/</span> folder in your workspace (.roorules and .clinerules are deprecated and will stop working soon)."
5455
},
5556
"systemPrompt": {

0 commit comments

Comments
 (0)