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
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 @@ -1010,6 +1014,7 @@ export function ChatView({
sessionId,
messageId,
thinkingLevel,
...(sessionPrompt ? { systemPrompt: sessionPrompt.body } : {}),
workingDir: conversation.workingDir,
approvalGateAvailable: approvalEnabled,
...(attachedBlocks && attachedBlocks.length > 0
Expand Down Expand Up @@ -1055,6 +1060,7 @@ export function ChatView({
sessionId,
messageId,
thinkingLevel,
...(sessionPrompt ? { systemPrompt: sessionPrompt.body } : {}),
workingDir: conversation.workingDir,
approvalGateAvailable: approvalEnabled,
approvalSessionMatcher,
Expand Down Expand Up @@ -1334,6 +1340,7 @@ export function ChatView({
conversation.workingDir,
effectiveModel,
thinkingLevel,
sessionPrompt,
sessionId,
contextWindow,
backend,
Expand Down Expand Up @@ -1799,6 +1806,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}
/>
)
}
65 changes: 58 additions & 7 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 { 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,22 @@ 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 library shows up without a reload.
const refreshPromptCommands = useCallback(() => {
void loadPromptSlashCommands().then(setPromptCommands)
}, [])

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 +76,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 +125,7 @@ export function SlashCommandsPlugin({
onSelectOption={onSelectOption}
onOpen={() => {
if (menuOpenRef) menuOpenRef.current = true
refreshPromptCommands()
}}
onClose={() => {
if (menuOpenRef) menuOpenRef.current = false
Expand Down
44 changes: 44 additions & 0 deletions console/web/src/hooks/use-prompts-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
isWorkerPresent,
useWorkerPresence,
type WorkerPresence,
} from './use-worker-presence'

/**
* Presence probe for the directory worker, which serves the prompt store
* (`directory::prompts::*`): worker-shipped slash templates plus the user
* prompt library. OPTIONAL, so the console gates the Prompts page on its
* presence rather than rendering controls that would call functions that
* don't exist. Thin wrapper over the generic worker-presence probe.
*/

/** Engine worker name for the directory worker. */
const PROMPTS_WORKER_NAME = 'iii-directory'
/** Base id for the browser-local handler bound to the `worker` trigger. */
const PROMPTS_WATCH_FN = 'console::prompts-watch'

export type PromptsStatus = WorkerPresence

/**
* @param enabled - only run against the real backend; pass `false` for the
* mock/Storybook backend (treats the worker as present so its UI shows
* in isolation).
*/
export function usePromptsStatus(enabled: boolean): PromptsStatus {
return useWorkerPresence({
workerName: PROMPTS_WORKER_NAME,
watchFnId: PROMPTS_WATCH_FN,
enabled,
})
}

/**
* Whether the directory worker's prompt functions are registered and safe
* to trigger. False during the initial presence probe and while the worker
* is absent.
*/
export function isPromptsAvailable(status: PromptsStatus): boolean {
return isWorkerPresent(status)
}

export { PROMPTS_WATCH_FN, PROMPTS_WORKER_NAME }
2 changes: 2 additions & 0 deletions console/web/src/lib/backend/harness-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export type HarnessSendMode = 'ask' | 'agent'
/** Per-send options frozen onto the turn record. */
export interface HarnessSendOptions {
system_prompt?: string
/** How a caller prompt combines with the built-in identity: override replaces it verbatim. */
system_prompt_strategy?: 'override' | 'enrich'
mode?: HarnessSendMode
max_turns?: number
thinking_level?: HarnessThinkingLevel
Expand Down
6 changes: 6 additions & 0 deletions console/web/src/lib/backend/real.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,12 @@ async function buildSendRequest(
options: {
mode,
functions: functionPolicy,
...(opts?.systemPrompt
? {
system_prompt: opts.systemPrompt,
system_prompt_strategy: 'override' as const,
}
: {}),
...(thinkingLevel ? { thinking_level: thinkingLevel } : {}),
...(providerOptions ? { provider_options: providerOptions } : {}),
metadata: buildTurnMetadata(sessionId, messageId, opts?.workingDir),
Expand Down
5 changes: 5 additions & 0 deletions console/web/src/lib/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ export type StreamEvent =

export interface ChatStreamOptions {
signal?: AbortSignal
/**
* Full system prompt override for this send (a kind: system entry from
* the prompt store). Omitted = the harness identity chain.
*/
systemPrompt?: string
/**
* Reasoning effort for the turn. `default` omits the override; exact native
* values also travel through namespaced provider options.
Expand Down
Loading
Loading