diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py
index e1688c4c..ea72414a 100644
--- a/backend/apps/agents/agents.py
+++ b/backend/apps/agents/agents.py
@@ -67,6 +67,14 @@ async def list_sessions(dashboard_id: str = ""):
sessions = agent_manager.get_all_sessions(dashboard_id=dashboard_id or None)
return {"sessions": [p_session_list_item(s) for s in sessions]}
+@agents.router.get("/predict-prompts")
+async def predict_prompts_route(count: int = 5):
+ """Guess a few prompts the user might type next, in their own voice, from what they've already
+ worked on. Drives the composer's ghost-text suggestion. Fails open to [] (no signal / no
+ provider / error), so the composer just keeps its static placeholder."""
+ from backend.apps.agents.manager.predict_prompts import predict_prompts
+ return {"suggestions": await predict_prompts(count=max(1, min(count, 8)))}
+
@agents.router.get("/activity")
async def agent_activity():
"""How many agent tasks are live right now, plus seconds until the next scheduled
diff --git a/backend/apps/agents/manager/predict_prompts.py b/backend/apps/agents/manager/predict_prompts.py
new file mode 100644
index 00000000..f41ebad8
--- /dev/null
+++ b/backend/apps/agents/manager/predict_prompts.py
@@ -0,0 +1,116 @@
+"""Aux-LLM prompt prediction: guess a few prompts the user might type next, in their own voice,
+from what they've already worked on (recent chat topics + onboarding starters). Provider-agnostic
+(cheap tier of whichever provider is connected); fail-open to [] so the composer just falls back to
+its static placeholder when there is no signal, no provider, or the call errors."""
+
+import logging
+import re
+from typing import List
+
+from typeguard import typechecked
+
+from backend.apps.agents.core.aux_llm import aux_max_tokens_for
+from backend.apps.agents.manager.session.session_store import load_all_session_data
+from backend.apps.settings.settings import load_settings
+
+logger = logging.getLogger(__name__)
+
+MAX_TOPICS = 24
+MAX_SUGGESTIONS = 5
+# Names the aux title-gen hands out for empty/greeting chats; they carry no topic signal.
+P_SKIP_NAMES = {"untitled", "new chat", "greeting", "chat", ""}
+
+
+def p_recent_topics(limit: int = MAX_TOPICS) -> List[str]:
+ """Recent chat topic titles (the aux-distilled 2-4 word names), newest first, deduped."""
+ data = load_all_session_data()
+ data.sort(
+ key=lambda pair: pair[1].get("closed_at") or pair[1].get("created_at") or "",
+ reverse=True,
+ )
+ topics: List[str] = []
+ seen = set()
+ for _sid, d in data:
+ name = (d.get("name") or "").strip()
+ low = name.lower()
+ if low in P_SKIP_NAMES or low in seen:
+ continue
+ seen.add(low)
+ topics.append(name)
+ if len(topics) >= limit:
+ break
+ return topics
+
+
+def p_parse_lines(raw: str, count: int) -> List[str]:
+ """One suggestion per line; strip bullets/numbering/quotes, drop empties, cap at count."""
+ out: List[str] = []
+ for line in raw.splitlines():
+ s = line.strip()
+ s = re.sub(r"^\s*(?:[-*•]|\d+[.)])\s*", "", s).strip()
+ s = s.strip('"“”‘’')
+ if s and len(s) <= 140:
+ out.append(s)
+ if len(out) >= count:
+ break
+ return out
+
+
+@typechecked
+async def predict_prompts(count: int = MAX_SUGGESTIONS) -> List[str]:
+ """Predict up to `count` short prompts the user might type next, in their style. [] on any miss."""
+ try:
+ from backend.apps.settings.credentials import get_anthropic_client_for_model
+ from backend.apps.agents.providers.registry import resolve_aux_model
+
+ global_settings = load_settings()
+ topics = p_recent_topics()
+ starters = [
+ (s.prompt or "").strip()
+ for s in (global_settings.personalized_starters or [])
+ if getattr(s, "prompt", None)
+ ]
+ # Nothing to personalize from: let the composer keep its static placeholder.
+ if not topics and not starters:
+ return []
+
+ aux_model = (await resolve_aux_model(global_settings, preferred_tier="haiku"))[0]
+ client = get_anthropic_client_for_model(global_settings, aux_model)
+
+ name = (global_settings.user_name or "").strip()
+ signal_lines: List[str] = []
+ if topics:
+ signal_lines.append("Recent things they worked on: " + "; ".join(topics))
+ if starters:
+ signal_lines.append("Tasks they were interested in: " + "; ".join(starters[:6]))
+ signal = "\n".join(signal_lines)
+
+ system_prompt = (
+ "You predict what a user is likely to type next into their AI agent platform, based on "
+ "what they already work on. You NEVER answer or explain; you only produce plausible next "
+ "prompts in the USER'S voice (imperative, first person, the way someone types to their "
+ "own assistant), matching their topics and phrasing.\n\n"
+ f"Return exactly {count} short prompts, one per line, no numbering, no quotes, no preamble. "
+ "Each is a single line under ~90 characters, concrete and immediately actionable. Vary "
+ "them across the topics; do not repeat a task they clearly just finished verbatim."
+ )
+ user_turn = (
+ (f"The user's name is {name}.\n" if name else "")
+ + "Here is what this user works on:\n\n"
+ + signal
+ + f"\n\n\nPredict {count} prompts they might type next."
+ )
+
+ chunks: List[str] = []
+ async with client.messages.stream(
+ model=aux_model,
+ max_tokens=aux_max_tokens_for(aux_model, base=300),
+ system=system_prompt,
+ messages=[{"role": "user", "content": user_turn}],
+ ) as stream:
+ async for text in stream.text_stream:
+ chunks.append(text)
+ return p_parse_lines("".join(chunks), count)
+ except Exception as e:
+ logger.info(f"[predict-prompts] fail-open ([]): {e}")
+ return []
diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx
index 6027f1cb..b4cffe7b 100644
--- a/frontend/src/app/pages/AgentChat/ChatInput.tsx
+++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx
@@ -48,14 +48,19 @@ interface Props {
prefillPrompt?: string;
// Replaces the default "Agent, @ for context..." placeholder (e.g. "Ask about this run...").
placeholderOverride?: string;
+ // Predicted next prompt (in the user's voice) shown as ghost text in the empty composer; Tab fills it.
+ ghostSuggestion?: string;
// A workflow run shown as a small removable chip inside the composer.
runContext?: WorkflowsRunContext;
onClearRunContext?: () => void;
}
-const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt, placeholderOverride, runContext, onClearRunContext }, ref) => {
+const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange, onActivityLabelChange, prefillPrompt, placeholderOverride, ghostSuggestion, runContext, onClearRunContext }, ref) => {
const c = useClaudeTokens();
const editorRef = useRef(null);
+ // Live ref so the keydown closure reads the current suggestion without re-creating handlers.
+ const ghostSuggestionRef = useRef('');
+ ghostSuggestionRef.current = ghostSuggestion || '';
const containerRef = useRef(null);
const generalFileInputRef = useRef(null);
const dispatch = useAppDispatch();
@@ -298,6 +303,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
elementSelection, setHasContent, setAttachedSkills, setForcedTools, onModeChange,
addImageFiles, uploadAndAttachFiles, handleSend,
onPasteExpand: setPreviewPasteId,
+ ghostSuggestionRef,
});
useDraftLoad(editorRef, ownerId, setPreviewPasteId, removePasteCard, c.font.mono, c.status.error);
@@ -353,6 +359,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
queueLength={queueLength}
modeConf={modeConf}
placeholderOverride={placeholderOverride}
+ ghostSuggestion={ghostSuggestion}
runContext={runContext}
onClearRunContext={onClearRunContext}
handleInput={handleInput}
diff --git a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts
index a743fece..3200bab6 100644
--- a/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts
+++ b/frontend/src/app/pages/AgentChat/ChatInput/hooks/useEditorHandlers.ts
@@ -62,13 +62,15 @@ interface Params {
uploadAndAttachFiles: (files: File[]) => void;
handleSend: () => void;
onPasteExpand: (pasteId: string) => void;
+ // Ghost-text prediction shown in the empty composer; Tab fills it as real, editable text.
+ ghostSuggestionRef?: RefObject;
}
export function useEditorHandlers(p: Params) {
const {
editorRef, generalFileInputRef, ownerId, sessionId, autoRunMode, c, skills,
elementSelection, setHasContent, setAttachedSkills, setForcedTools, onModeChange,
- addImageFiles, uploadAndAttachFiles, handleSend, onPasteExpand,
+ addImageFiles, uploadAndAttachFiles, handleSend, onPasteExpand, ghostSuggestionRef,
} = p;
const dispatch = useAppDispatch();
const [picker, setPicker] = useState(EMPTY_TRIGGER);
@@ -233,6 +235,33 @@ export function useEditorHandlers(p: Params) {
e.preventDefault();
return;
}
+ // Tab accepts the ghost prediction (Copilot-style): only when the editor is empty and a
+ // suggestion is showing, so Tab keeps its normal meaning the instant the user starts typing.
+ if (e.key === 'Tab' && !e.shiftKey) {
+ const ghost = ghostSuggestionRef?.current || '';
+ const editor = editorRef.current;
+ if (ghost && editor && !readEditorText(editor).trim()) {
+ e.preventDefault();
+ if (isTextareaEl(editor)) {
+ editor.value = ghost;
+ editor.setSelectionRange(ghost.length, ghost.length);
+ } else {
+ editor.textContent = ghost;
+ const sel = window.getSelection();
+ if (sel) {
+ const range = document.createRange();
+ range.selectNodeContents(editor);
+ range.collapse(false);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ }
+ }
+ editor.focus();
+ updateHasContent();
+ scheduleDraftSave(ownerId, () => readEditorHTML(editor));
+ return;
+ }
+ }
if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key.toLowerCase())) {
e.preventDefault();
return;
diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx
index a90c8f5c..a224040c 100644
--- a/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx
+++ b/frontend/src/app/pages/AgentChat/ChatInput/view/ChatInputView.tsx
@@ -61,6 +61,7 @@ interface Props {
queueLength: number;
modeConf: ModeConf;
placeholderOverride?: string;
+ ghostSuggestion?: string;
runContext?: WorkflowsRunContext;
onClearRunContext?: () => void;
handleInput: () => void;
@@ -238,6 +239,7 @@ export const ChatInputView: React.FC = (p) => {
isRunning={p.isRunning}
queueLength={p.queueLength}
placeholderLabel={p.placeholderOverride ?? 'Ask anything, @ for context, / for commands'}
+ ghostSuggestion={p.ghostSuggestion}
onInput={p.handleInput}
onClick={p.handleEditorClick}
onKeyDown={p.handleKeyDown}
diff --git a/frontend/src/app/pages/AgentChat/ChatInput/view/EditorSurface.tsx b/frontend/src/app/pages/AgentChat/ChatInput/view/EditorSurface.tsx
index 03977d82..801444ea 100644
--- a/frontend/src/app/pages/AgentChat/ChatInput/view/EditorSurface.tsx
+++ b/frontend/src/app/pages/AgentChat/ChatInput/view/EditorSurface.tsx
@@ -12,6 +12,7 @@ interface Props {
isRunning?: boolean;
queueLength: number;
placeholderLabel: string;
+ ghostSuggestion?: string;
onInput: () => void;
onClick: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
@@ -20,15 +21,18 @@ interface Props {
export const EditorSurface: React.FC = ({
c, editorRef, disabled, hasContent, hasAttachments, autoRunMode, isRunning, queueLength,
- placeholderLabel, onInput, onClick, onKeyDown, onPaste,
+ placeholderLabel, ghostSuggestion, onInput, onClick, onKeyDown, onPaste,
}) => {
+ // A live prediction outranks the static placeholder while the box is empty and idle; the Tab pill
+ // tells the user how to take it. Falls back to the placeholder the moment there's no suggestion.
+ const showGhost = !!ghostSuggestion && !disabled && !autoRunMode && !isRunning;
const placeholderText = disabled
? 'Agent is working...'
: autoRunMode
? 'Describe what data to generate…'
: isRunning
? (queueLength > 0 ? `${queueLength} queued, type another or wait…` : 'Agent is working, messages will queue…')
- : placeholderLabel;
+ : showGhost ? ghostSuggestion! : placeholderLabel;
return (
@@ -72,9 +76,31 @@ export const EditorSurface: React.FC = ({
fontFamily: 'inherit',
pointerEvents: 'none',
userSelect: 'none',
+ display: 'flex',
+ alignItems: 'center',
+ gap: 8,
}}
>
- {placeholderText}
+
+ {placeholderText}
+
+ {showGhost && (
+
+ Tab
+
+ )}
)}
diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx
index 92c4558a..179a0848 100644
--- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx
+++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx
@@ -20,6 +20,7 @@ import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { searchHistory, clearHistorySearch } from '@/shared/state/agentsSlice';
import { updateSettingsPatch, AppSettings } from '@/shared/state/settingsSlice';
import { store } from '@/shared/state/store';
+import { API_BASE, getAuthToken } from '@/shared/config';
import type { Output } from '@/shared/state/outputsSlice';
interface Props {
@@ -94,6 +95,36 @@ const DashboardToolbar = React.forwardRef(
settingsApplied.current = true;
}
}, [settingsLoaded, defaultMode, defaultModel, defaultThinkingLevel]);
+ // Ghost-text predictions: what the user might type next, in their own voice. Fetched once per app
+ // load (cached), then one is shown at a time and cycled while the composer sits idle+empty. Empty
+ // list (no signal / no provider / error) just leaves the static "What should I do sir..." placeholder.
+ const [ghostList, setGhostList] = useState([]);
+ const [ghostIdx, setGhostIdx] = useState(0);
+ const ghostFetchedRef = useRef(false);
+ useEffect(() => {
+ if (!inputOpen || ghostFetchedRef.current) return;
+ ghostFetchedRef.current = true;
+ (async () => {
+ try {
+ const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
+ const headers: Record = {};
+ if (tok) headers['Authorization'] = `Bearer ${tok}`;
+ const resp = await fetch(`${API_BASE}/agents/predict-prompts?count=5`, { headers });
+ if (!resp.ok) return;
+ const data = await resp.json();
+ if (Array.isArray(data.suggestions)) setGhostList(data.suggestions.filter((s: unknown) => typeof s === 'string' && s));
+ } catch { /* fail open: keep the static placeholder */ }
+ })();
+ }, [inputOpen]);
+ // Rotate the visible suggestion every few seconds while the composer is open, so the user sees a
+ // few different ideas instead of one. Cheap; the list is already fetched and cached.
+ useEffect(() => {
+ if (!inputOpen || ghostList.length <= 1) return undefined;
+ const t = setInterval(() => setGhostIdx((i) => (i + 1) % ghostList.length), 4500);
+ return () => clearInterval(t);
+ }, [inputOpen, ghostList.length]);
+ const ghostSuggestion = ghostList.length ? ghostList[ghostIdx % ghostList.length] : undefined;
+
// Reset defaults on each new compose session so in-session picks don't leak into the next new-chat draft.
const prevInputOpen = useRef(false);
useEffect(() => {
@@ -417,6 +448,7 @@ const DashboardToolbar = React.forwardRef(
onThinkingLevelChange={handleThinkingLevelChange}
prefillPrompt={prefillPrompt}
placeholderOverride="What should I do sir..."
+ ghostSuggestion={ghostSuggestion}
/>