Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions console/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Browser } from '@/pages/Browser'
import { Configuration } from '@/pages/Configuration'
import { Github } from '@/pages/Github'
import { Memory } from '@/pages/Memory'
import { Prompts } from '@/pages/Prompts'
import { TracesV2 } from '@/pages/TracesV2'
import { Workers } from '@/pages/Workers'
import { Worktrees } from '@/pages/Worktrees'
Expand Down Expand Up @@ -101,6 +102,8 @@ export function App() {
<Browser />
) : view === 'memory' ? (
<Memory />
) : view === 'prompts' ? (
<Prompts />
) : view === 'github' ? (
<Github />
) : (
Expand Down Expand Up @@ -137,11 +140,13 @@ function Header({
browserAvailable,
memoryAvailable,
githubAvailable,
promptsAvailable,
} = useConversationsCtx()
const viewOptions = buildViewOptions(
worktreeAvailable,
browserAvailable,
memoryAvailable,
promptsAvailable,
githubAvailable,
)
const onConsoleSettings = view === 'configuration'
Expand Down
13 changes: 13 additions & 0 deletions console/web/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { Composer, type ComposerSubmitPayload } from './Composer'
import { ContextUsage } from './ContextUsage'
import { ExportSessionButton } from './ExportSessionButton'
import { MessageList } from './MessageList'
import type { SessionPrompt } from './PromptPicker'
import { SessionTriggers } from './SessionTriggers'
import { WorktreeBadge } from './WorktreeBadge'

Expand Down Expand Up @@ -157,6 +158,9 @@ export function ChatView({
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>(
DEFAULT_THINKING_LEVEL,
)
// System prompt override for this conversation (prompt store, kind:
// system). Applied to every send until switched back to default.
const [sessionPrompt, setSessionPrompt] = useState<SessionPrompt | null>(null)
Comment on lines +161 to +163

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope the selected prompt by conversation.id.

This state can leak the previous chat’s override into the next selected conversation; if ChatView remounts instead, it loses the prior conversation’s selection. Store prompt selection keyed by conversation id (preferably in conversation state) and derive the current override from that mapping before both send paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@console/web/src/components/chat/ChatView.tsx` around lines 161 - 163, Replace
the single ChatView-level sessionPrompt state with prompt selections keyed by
conversation.id, preferably in the existing conversation state. Derive the
selected override for the active conversation before both send paths, so
switching conversations neither leaks the previous selection nor loses each
conversation’s prior selection; update the prompt-switching logic to write to
the active conversation’s entry.

const abortRef = useRef<AbortController | null>(null)
const [copied, setCopied] = useState(false)
const { functionEntries } = useFunctionsCatalog(backend.id)
Expand Down Expand Up @@ -1003,6 +1007,7 @@ export function ChatView({
sessionId,
messageId,
thinkingLevel,
...(sessionPrompt ? { systemPrompt: sessionPrompt.body } : {}),
workingDir: conversation.workingDir,
approvalGateAvailable: approvalEnabled,
...(attachedBlocks && attachedBlocks.length > 0
Expand Down Expand Up @@ -1048,6 +1053,7 @@ export function ChatView({
sessionId,
messageId,
thinkingLevel,
...(sessionPrompt ? { systemPrompt: sessionPrompt.body } : {}),
workingDir: conversation.workingDir,
approvalGateAvailable: approvalEnabled,
approvalSessionMatcher,
Expand Down Expand Up @@ -1327,6 +1333,7 @@ export function ChatView({
conversation.workingDir,
effectiveModel,
thinkingLevel,
sessionPrompt,
sessionId,
contextWindow,
backend,
Expand Down Expand Up @@ -1788,6 +1795,12 @@ export function ChatView({
backend.id === 'real' &&
(conversationsCtx?.memoryAvailable ?? false)
}
showPromptPicker={
backend.id === 'real' &&
(conversationsCtx?.promptsAvailable ?? false)
}
sessionPrompt={sessionPrompt}
onSessionPromptChange={setSessionPrompt}
memoryBank={conversation.memoryBank ?? null}
onMemoryBankChange={(next) =>
conversationsCtx?.setMemoryBank(conversation.id, next)
Expand Down
16 changes: 16 additions & 0 deletions console/web/src/components/chat/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { DirectoryPicker, type WorktreePickerOptions } from './DirectoryPicker'
import { LexicalShell } from './LexicalShell'
import { ModelPicker } from './ModelPicker'
import { ModePicker } from './ModePicker'
import { PromptPicker, type SessionPrompt } from './PromptPicker'
import { nextHistoryTarget } from './queue-history'

export interface ComposerSubmitPayload {
Expand Down Expand Up @@ -64,6 +65,11 @@ interface ComposerProps {
workingDir?: string | null
/** Show the memory bank picker (memory worker present, real backend). */
showMemoryBank?: boolean
/** Show the system prompt picker (directory worker present, real backend). */
showPromptPicker?: boolean
/** This chat's system prompt override; null = the harness default chain. */
sessionPrompt?: SessionPrompt | null
onSessionPromptChange?: (next: SessionPrompt | null) => void
/** This chat's memory bank; null = the worker's default bank. */
memoryBank?: string | null
onMemoryBankChange?: (next: string | null) => void
Expand Down Expand Up @@ -150,6 +156,9 @@ export function Composer({
workingDir,
showMemoryBank,
memoryBank,
showPromptPicker,
sessionPrompt,
onSessionPromptChange,
onMemoryBankChange,
workingDirLocked,
workingDirError,
Expand Down Expand Up @@ -358,6 +367,13 @@ export function Composer({
disabled={optionsDisabled}
/>
) : null}
{showPromptPicker && onSessionPromptChange ? (
<PromptPicker
value={sessionPrompt ?? null}
onChange={onSessionPromptChange}
disabled={optionsDisabled}
/>
) : null}
{showPermissionMode ? (
<PermissionModePicker
value={permissionMode}
Expand Down
99 changes: 99 additions & 0 deletions console/web/src/components/chat/PromptPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from 'react'
import { Select } from '@/components/ui/Select'
import { getPrompt, listPrompts, type PromptRow } from '@/lib/prompts'

/**
* In-chat system prompt picker — "which system prompt is this chat
* running". `default` defers to the harness identity chain (router
* override, provider-declared, embedded fallback). Picking a `kind:
* system` entry from the prompt store overrides the system prompt for
* every following send in THIS conversation, so a five-line special-
* purpose prompt (blog writing, scraping) fully replaces the general
* agent prompt. Lives next to the composer as one compact dropdown;
* mid-conversation switches apply from the next turn.
*/

export interface SessionPrompt {
name: string
body: string
}

interface PromptPickerProps {
/** Prompt for THIS conversation; null = the harness default chain. */
value: SessionPrompt | null
onChange: (next: SessionPrompt | null) => void
disabled?: boolean
}

const DEFAULT = '(default)'

export function PromptPicker({ value, onChange, disabled }: PromptPickerProps) {
const [prompts, setPrompts] = useState<PromptRow[]>([])
// Monotonic token so a slow getPrompt from an earlier selection can't
// land after a newer one and apply a stale prompt.
const selectionRef = useRef(0)

useEffect(() => {
let cancelled = false
void listPrompts()
.then((next) => {
if (!cancelled) {
setPrompts(next.filter((p) => p.kind === 'system'))
}
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])

const known = prompts.some((p) => p.name === value?.name)
const options = [
{
value: DEFAULT,
label: 'prompt: default',
title:
'the harness identity chain (router override, provider prompt, fallback)',
},
...prompts.map((p) => ({
value: p.name,
label: `prompt: ${p.name}`,
title: p.description,
})),
...(value && !known
? [
{
value: value.name,
label: `prompt: ${value.name}`,
title: 'no longer in the prompt store; still applied to this chat',
},
]
: []),
]

return (
<Select<string>
value={value?.name ?? DEFAULT}
onChange={(next) => {
// Every selection (including DEFAULT) supersedes any in-flight read.
const token = ++selectionRef.current
if (next === DEFAULT) {
onChange(null)
return
}
void getPrompt(next)
.then((detail) => {
if (token !== selectionRef.current) return
if (detail) onChange({ name: detail.name, body: detail.body })
})
.catch(() => {
// Failed read: leave the current selection untouched.
})
}}
disabled={disabled}
aria-label="system prompt"
className="min-w-[104px] max-w-[180px]"
options={options}
/>
)
}
74 changes: 66 additions & 8 deletions console/web/src/components/chat/lexical/SlashCommandsPlugin.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
LexicalTypeaheadMenuPlugin,
MenuOption,
} from '@lexical/react/LexicalTypeaheadMenuPlugin'
import {
$createTextNode,
$getNodeByKey,
$isTextNode,
COMMAND_PRIORITY_NORMAL,
type LexicalEditor,
type TextNode,
} from 'lexical'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { fuzzyFilterSlash, type SlashCommand } from '@/lib/slash-commands'
import { getPrompt } from '@/lib/prompts'
import {
fuzzyFilterSlash,
loadPromptSlashCommands,
SLASH_COMMANDS,
type SlashCommand,
} from '@/lib/slash-commands'
import { FlipMenu } from './FlipMenu'

class SlashCommandOption extends MenuOption {
Expand All @@ -27,7 +36,8 @@ interface SlashCommandsPluginProps {
}

// Anchored at column 0 so `/` mid-sentence ("either/or") doesn't fire.
const SLASH_PATTERN = /^\/(\w*)$/
// `-` is allowed because prompt names are kebab-case (`/blog-writer`).
const SLASH_PATTERN = /^\/([\w-]*)$/

function slashTriggerFn(text: string, _editor: LexicalEditor) {
const match = text.match(SLASH_PATTERN)
Expand All @@ -42,14 +52,29 @@ function slashTriggerFn(text: string, _editor: LexicalEditor) {
export function SlashCommandsPlugin({
menuOpenRef,
}: SlashCommandsPluginProps = {}) {
const [editor] = useLexicalComposerContext()
const [query, setQuery] = useState<string | null>(null)
const [promptCommands, setPromptCommands] = useState<SlashCommand[]>([])

// The prompt store changes rarely; re-read each time the menu opens so a
// save in the prompts page shows up without a reload.
const [openCount, setOpenCount] = useState(0)
useEffect(() => {
let cancelled = false
loadPromptSlashCommands().then((commands) => {
if (!cancelled) setPromptCommands(commands)
})
return () => {
cancelled = true
}
}, [openCount])

const options = useMemo(
() =>
fuzzyFilterSlash(query ?? '').map(
fuzzyFilterSlash(query ?? '', [...SLASH_COMMANDS, ...promptCommands]).map(
(entry) => new SlashCommandOption(entry),
),
[query],
[query, promptCommands],
)

const onSelectOption = useCallback(
Expand All @@ -58,14 +83,46 @@ export function SlashCommandsPlugin({
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
) => {
if (textNodeContainingQuery) {
const replacement = $createTextNode(`${option.entry.command} `)
const { entry } = option
if (!textNodeContainingQuery) {
closeMenu()
return
}
if (!entry.promptName) {
const replacement = $createTextNode(`${entry.command} `)
textNodeContainingQuery.replace(replacement)
replacement.selectEnd()
closeMenu()
return
}
// Prompt entry: inject the prompt BODY into the message (claude-code
// style context injection). The body is fetched async, so drop a
// placeholder now and swap it once the read lands.
const placeholder = $createTextNode(`${entry.command} `)
textNodeContainingQuery.replace(placeholder)
placeholder.selectEnd()
const key = placeholder.getKey()
const name = entry.promptName
closeMenu()
void getPrompt(name).then((detail) => {
editor.update(() => {
const node = $getNodeByKey(key)
if (!node || !detail) return
// Swap only while the node still holds the untouched placeholder.
// If the user kept typing into it while the read was pending, leave
// their text alone rather than clobbering it.
if (
!$isTextNode(node) ||
node.getTextContent() !== `${entry.command} `
)
return
const body = $createTextNode(`${detail.body.trim()}\n`)
node.replace(body)
body.selectEnd()
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
},
[],
[editor],
)

return (
Expand All @@ -75,6 +132,7 @@ export function SlashCommandsPlugin({
onSelectOption={onSelectOption}
onOpen={() => {
if (menuOpenRef) menuOpenRef.current = true
setOpenCount((n) => n + 1)
}}
onClose={() => {
if (menuOpenRef) menuOpenRef.current = false
Expand Down
6 changes: 6 additions & 0 deletions console/web/src/hooks/use-hash-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type View =
| 'worktrees'
| 'browser'
| 'memory'
| 'prompts'
| 'github'

export interface WorkersConfigurationRoute {
Expand Down Expand Up @@ -98,6 +99,9 @@ function routeFromHash(hash: string): View | null {
if (hash === '#/memory') {
return 'memory'
}
if (hash === '#/prompts') {
return 'prompts'
}
if (hash === '#/github') {
return 'github'
}
Expand Down Expand Up @@ -134,6 +138,8 @@ function hashFor(view: View): string {
return '#/browser'
case 'memory':
return '#/memory'
case 'prompts':
return '#/prompts'
case 'github':
return '#/github'
case 'configuration':
Expand Down
Loading
Loading