Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
08ca316
πŸ€– feat: Instructions tab in right sidebar
ammar-agent May 10, 2026
598ca40
πŸ€– refactor: unify right-sidebar tab registry; add Instructions to def…
ammar-agent May 10, 2026
d881d1c
πŸ€– fix: parent AGENTS.md for sub-project workspaces; align Instruction…
ammar-agent May 10, 2026
00a958e
πŸ€– fix: keep Instructions tab default-visible and unblock CI
ammar-agent May 10, 2026
07501b1
πŸ€– feat: workspace additional system context scratchpad
ammar-agent May 10, 2026
d9c68b8
πŸ€– fix: mock additional system context in Storybook
ammar-agent May 10, 2026
8b2b643
πŸ€– fix: use live scratchpad context for sends
ammar-agent May 10, 2026
0e9b898
πŸ€– fix: isolate scratchpad save generations
ammar-agent May 10, 2026
f42cdaa
πŸ€– fix: send live scratchpad separately
ammar-agent May 10, 2026
38e270e
πŸ€– fix: keep scratchpad save queue across unmounts
ammar-agent May 10, 2026
02ad108
πŸ€– fix: hydrate scratchpad before live override
ammar-agent May 10, 2026
2abd728
πŸ€– fix: ignore stale scratchpad save responses
ammar-agent May 10, 2026
24f6b9a
πŸ€– tests: update sidebar archive layout expectation
ammar-agent May 10, 2026
7825079
πŸ€– tests: account for Instructions tab in sidebar e2e
ammar-agent May 10, 2026
5a60c99
πŸ€– feat: count badge for Instructions tab label
ammar-agent May 10, 2026
ce8eadf
πŸ€– feat: Chat Instructions toggle + chat decoration + delayed save flash
ammar-agent May 12, 2026
344b36d
πŸ€– refactor: Chat Instructions decoration as link + orange dirty asterisk
ammar-agent May 12, 2026
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
18 changes: 13 additions & 5 deletions src/browser/components/ChatPane/ChatInputDecoration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ interface ChatInputDecorationProps {
summaryClassName?: string;
contentClassName?: string;
dataComponent?: string;
/**
* Optional icon rendered in the trailing slot of the collapsed row. When
* omitted the component shows the default expand/collapse chevron. Pass a
* custom icon (e.g. an external-link / arrow) for "link-style" decorations
* whose `onToggle` navigates elsewhere instead of expanding inline.
*/
trailingIcon?: ReactNode;
}

// Keep collapsible decorations aligned with the chat input gutter so swapping
Expand All @@ -37,11 +44,12 @@ export function ChatInputDecoration(props: ChatInputDecorationProps) {
>
{props.summary}
<div className="ml-auto">
{props.expanded ? (
<ChevronDown className="text-muted group-hover:text-secondary size-3.5 transition-colors" />
) : (
<ChevronRight className="text-muted group-hover:text-secondary size-3.5 transition-colors" />
)}
{props.trailingIcon ??
(props.expanded ? (
<ChevronDown className="text-muted group-hover:text-secondary size-3.5 transition-colors" />
) : (
<ChevronRight className="text-muted group-hover:text-secondary size-3.5 transition-colors" />
))}
</div>
</button>
{props.expanded && props.renderExpanded && (
Expand Down
8 changes: 8 additions & 0 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import React, {
import { Lightbulb } from "lucide-react";
import { MessageListProvider } from "@/browser/features/Messages/MessageListContext";
import { cn } from "@/common/lib/utils";
import { ChatInstructionsChatDecoration } from "@/browser/components/InstructionsTab/AdditionalSystemContextScratchpad";
import { MessageRenderer } from "@/browser/features/Messages/MessageRenderer";
import { MarkdownRenderer } from "@/browser/features/Messages/MarkdownRenderer";
import { useTranscriptContextMenu } from "@/browser/features/Messages/useTranscriptContextMenu";
Expand Down Expand Up @@ -1140,6 +1141,13 @@ const ChatInputPane: React.FC<ChatInputPaneProps> = (props) => {
key: "background-processes",
node: <BackgroundProcessesBanner workspaceId={props.workspaceId} />,
});
// The Chat Instructions decoration is intentionally self-gating: it renders
// nothing when the scratchpad is empty or disabled, so it can always be in
// the decoration lane without affecting layout for users who don't use it.
decorationEntries.push({
key: "chat-instructions",
node: <ChatInstructionsChatDecoration workspaceId={props.workspaceId} />,
});
if (props.shouldShowReviewsBanner) {
decorationEntries.push({
key: "reviews-banner",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowUpRight, BookOpen } from "lucide-react";

import { ChatInputDecoration } from "@/browser/components/ChatPane/ChatInputDecoration";
import { useAPI } from "@/browser/contexts/API";
import {
type AdditionalSystemContextSnapshot,
getAdditionalSystemContextFocusGeneration,
getAdditionalSystemContextVersion,
queueAdditionalSystemContextSave,
requestAdditionalSystemContextFocus,
subscribeAdditionalSystemContextFocus,
updateAdditionalSystemContextSnapshot,
useAdditionalSystemContextSnapshot,
} from "@/browser/utils/additionalSystemContextStore";
import { focusInstructionsTab } from "@/browser/utils/instructionsTabFocus";
import { cn } from "@/common/lib/utils";
import { getErrorMessage } from "@/common/utils/errors";

/** UI label used everywhere the user sees this feature. */
export const CHAT_INSTRUCTIONS_LABEL = "Chat Instructions";

/**
* Delay before flashing the "Saving…" indicator. The save itself queues
* instantly, but most writes complete in tens of milliseconds so flashing a
* status message every keystroke is jittery. We only surface it once the save
* takes longer than this β€” e.g. on slow filesystems or during retries.
*/
const SAVING_INDICATOR_DELAY_MS = 1000;

export function getChatInstructionsFirstLinePreview(content: string): string {
return content.split(/\r?\n/, 1)[0]?.trim() || "(blank first line)";
}

export function isChatInstructionsActive(snapshot: AdditionalSystemContextSnapshot): boolean {
return snapshot.enabled && snapshot.content.trim().length > 0;
}

interface ScratchpadState {
content: string;
enabled: boolean;
loading: boolean;
/** True once the save has been in flight longer than the indicator delay. */
savingVisible: boolean;
error: string | null;
setContent: (content: string) => void;
setEnabled: (enabled: boolean) => void;
}

export function useChatInstructions(workspaceId: string): ScratchpadState {
const { api } = useAPI();
const snapshot = useAdditionalSystemContextSnapshot(workspaceId);
const [loading, setLoading] = useState(true);
const [savingVisible, setSavingVisible] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirtyRef = useRef(false);
const mountedRef = useRef(true);
const savingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const clearSavingTimer = () => {
if (savingTimerRef.current != null) {
clearTimeout(savingTimerRef.current);
savingTimerRef.current = null;
}
};

useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
clearSavingTimer();
};
}, []);

useEffect(() => {
dirtyRef.current = false;
setLoading(true);
setSavingVisible(false);
clearSavingTimer();
setError(null);

if (!api) return;

const loadVersion = getAdditionalSystemContextVersion(workspaceId);
let cancelled = false;
api.workspace
.getAdditionalSystemContext({ workspaceId })
.then((result) => {
if (cancelled || !mountedRef.current) return;
if (!dirtyRef.current && getAdditionalSystemContextVersion(workspaceId) === loadVersion) {
updateAdditionalSystemContextSnapshot(workspaceId, {
content: result.content,
enabled: result.enabled,
});
}
setLoading(false);
})
.catch((err) => {
if (cancelled || !mountedRef.current) return;
setError(getErrorMessage(err));
setLoading(false);
});

return () => {
cancelled = true;
};
}, [api, workspaceId]);

const queue = useCallback(
(next: AdditionalSystemContextSnapshot) => {
dirtyRef.current = true;
updateAdditionalSystemContextSnapshot(workspaceId, next);
if (!api) return;
setError(null);
// Only show the "Saving…" message if the save takes more than a beat β€”
// sub-second writes shouldn't flicker the UI on every keystroke.
clearSavingTimer();
savingTimerRef.current = setTimeout(() => {
if (mountedRef.current) setSavingVisible(true);
savingTimerRef.current = null;
}, SAVING_INDICATOR_DELAY_MS);
queueAdditionalSystemContextSave(api, workspaceId, next, {
onError: (err) => {
if (!mountedRef.current) return;
setError(getErrorMessage(err));
},
onIdle: () => {
if (!mountedRef.current) return;
clearSavingTimer();
setSavingVisible(false);
},
});
},
[api, workspaceId]
);

const setContent = useCallback(
(content: string) => {
queue({ content, enabled: snapshot.enabled });
},
[queue, snapshot.enabled]
);

const setEnabled = useCallback(
(enabled: boolean) => {
queue({ content: snapshot.content, enabled });
},
[queue, snapshot.content]
);

return {
content: snapshot.content,
enabled: snapshot.enabled,
loading,
savingVisible,
error,
setContent,
setEnabled,
};
}

interface ChatInstructionsEditorProps {
workspaceId: string;
className?: string;
textareaClassName?: string;
minRows?: number;
placeholder?: string;
}

export function ChatInstructionsEditor(props: ChatInstructionsEditorProps) {
const state = useChatInstructions(props.workspaceId);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);

// Auto-grow the textarea so the editor "expands" as the user types instead
// of forcing them into a small scroll window.
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
}, [state.content]);

// Listen for focus-requests fired by the ChatInput decoration / badge so
// that clicking the decoration brings the user straight into the editor.
const lastFocusGenRef = useRef(getAdditionalSystemContextFocusGeneration(props.workspaceId));
useEffect(() => {
const focus = () => {
const generation = getAdditionalSystemContextFocusGeneration(props.workspaceId);
if (generation === lastFocusGenRef.current) return;
lastFocusGenRef.current = generation;
const textarea = textareaRef.current;
if (!textarea) return;
textarea.focus();
// Place caret at end without selecting so the user can keep typing.
const length = textarea.value.length;
textarea.setSelectionRange(length, length);
};
const unsubscribe = subscribeAdditionalSystemContextFocus(props.workspaceId, focus);
// Replay any generation bumped while we were unmounted (e.g. the tab was
// switched on for the first time during the focus request).
focus();
return unsubscribe;
}, [props.workspaceId]);

// Render a quiet hint when there's no save in progress; only flash the
// "Saving…" label after the >1s delay.
const statusText = state.loading
? "Loading…"
: state.savingVisible
? "Saving…"
: state.enabled
? "Saved Β· in effect every turn"
: "Saved Β· disabled (not sent to the agent)";

return (
<div className={cn("space-y-1.5", props.className)}>
<textarea
ref={textareaRef}
value={state.content}
rows={props.minRows ?? 3}
onChange={(event) => state.setContent(event.currentTarget.value)}
placeholder={
props.placeholder ??
"Add chat-scoped instructions that should be appended to the system prompt…"
}
className={cn(
"border-border bg-background text-foreground placeholder:text-muted min-h-[72px] w-full resize-none overflow-hidden rounded border px-3 py-2 text-xs leading-5 outline-none focus:ring-1 focus:ring-[var(--color-accent)]",
!state.enabled && "opacity-60",
props.textareaClassName
)}
aria-label={`${CHAT_INSTRUCTIONS_LABEL} editor`}
disabled={state.loading}
/>
<div className="text-muted flex min-h-4 items-center justify-between gap-2 text-[10px]">
<label className="flex cursor-pointer items-center gap-1.5 select-none">
<input
type="checkbox"
className="accent-[var(--color-accent)]"
checked={state.enabled}
disabled={state.loading}
onChange={(event) => state.setEnabled(event.currentTarget.checked)}
aria-label="Enable Chat Instructions"
/>
<span>{state.enabled ? "Enabled" : "Disabled"}</span>
</label>
<span className="min-w-0 truncate">{statusText}</span>
{state.error && (
<span className="text-destructive shrink-0 truncate" title={state.error}>
{state.error}
</span>
)}
</div>
</div>
);
}

export function ChatInstructionsPanel(props: { workspaceId: string }) {
return (
<section className="border-border border-b px-3 py-3">
<div className="mb-2 flex items-baseline justify-between gap-3">
<div>
<h3 className="text-xs font-medium">{CHAT_INSTRUCTIONS_LABEL}</h3>
<p className="text-muted mt-0.5 text-[10px]">
Workspace-scoped instructions appended to the system prompt for every chat turn. Toggle
off to keep the text around without sending it.
</p>
</div>
</div>
<ChatInstructionsEditor workspaceId={props.workspaceId} />
</section>
);
}

/**
* Compact decoration rendered in the chat-input decoration stack whenever
* {@link isChatInstructionsActive} is true. Visually matches the other
* decorations (TODO, background bashes, queued messages) via the shared
* {@link ChatInputDecoration} primitive, but acts as a link rather than an
* expandable panel: clicking opens the Instructions tab in the right sidebar
* and focuses the editor there. We render the trailing icon as an arrow
* instead of a chevron to signal "this opens elsewhere".
*/
export function ChatInstructionsChatDecoration(props: { workspaceId: string }) {
const snapshot = useAdditionalSystemContextSnapshot(props.workspaceId);
if (!isChatInstructionsActive(snapshot)) return null;

const handleOpenInPanel = () => {
focusInstructionsTab(props.workspaceId);
requestAdditionalSystemContextFocus(props.workspaceId);
};

return (
<ChatInputDecoration
// Pure link β€” never expands inline. The dedicated editor in the
// Instructions tab is the authoritative editing surface.
expanded={false}
onToggle={handleOpenInPanel}
dataComponent="ChatInstructionsChatDecoration"
trailingIcon={
<ArrowUpRight
aria-hidden="true"
className="text-muted group-hover:text-secondary size-3.5 transition-colors"
/>
}
summary={
<>
<BookOpen className="text-muted group-hover:text-secondary size-3.5 shrink-0 transition-colors" />
<span className="text-muted group-hover:text-secondary flex min-w-0 items-center gap-1 transition-colors">
<span className="shrink-0 font-medium">{CHAT_INSTRUCTIONS_LABEL}</span>
<span className="min-w-0 truncate">
Β· {getChatInstructionsFirstLinePreview(snapshot.content)}
</span>
</span>
</>
}
/>
);
}

// -----------------------------------------------------------------------------
// Back-compat aliases. The feature was originally named "Additional system
// context"; the user-facing label is now "Chat Instructions". Keep the old
// exports so call sites can be migrated incrementally if needed.
// -----------------------------------------------------------------------------
export const useAdditionalSystemContextScratchpad = useChatInstructions;
export const AdditionalSystemContextEditor = ChatInstructionsEditor;
export const AdditionalSystemContextPanel = ChatInstructionsPanel;
export const AdditionalSystemContextChatDecoration = ChatInstructionsChatDecoration;
Loading
Loading