Skip to content
Merged
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
32 changes: 28 additions & 4 deletions internal/dashboard/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,38 @@ const chatQuestionGuidance = "When a choice is genuinely ambiguous and a quick d
"```corral-question\nquestion: <the question>\n- <option one>\n- <option two>\n```\n" +
"One question per block, 2–4 short options. After the block, stop and wait for the answer."

// chatConductorGuidance is prepended to the GLOBAL chat's first turn so the
// most important operating rule is in front of the model from the start, NOT
// buried in the corral-api skill (which is loaded via --plugin-dir but only READ
// when the model chooses to invoke it — for a "run the tests on repo X" request
// it often never does, then does the code work inline on the host). This makes
// the sandbox-routing rule always-on.
//
// Only for the global chat (workspace==""): a project chat already runs INSIDE a
// sandbox, so this rule doesn't apply there.
const chatConductorGuidance = "YOU ARE A CONDUCTOR ON THE HOST — you are NOT sandboxed, and you must not do a " +
"repo's code work directly on this machine. If the request involves writing, running, building, testing, " +
"or exploring a repo's code (a feature, a bug, an issue, tests, getting an app running), do NOT `cd` into a " +
"local checkout and edit/run/commit/push it here. Instead create a sandbox project and hand it the task: " +
"first `corral api GET /repos` to find the repo id, then `corral api POST /projects/create -d " +
"'{\"repoId\":\"<id>\",\"prompt\":\"<the full task>\"}'` — the sandbox's own Claude does the work. Then " +
"supervise it (`corral api GET /status`, read its conversation). ONLY pure host/orchestration work " +
"(inspecting Corral state, reading logs/PRs, running flows, analysis, answering questions) stays here. When " +
"in doubt, route it to a sandbox. (See the corral-api skill for the exact API shapes.)"

// withContextHint prepends a page-context note (and, on the first turn, the
// question-asking convention) to a prompt. firstTurn gates both — later turns
// already carry them via --resume.
func withContextHint(prompt, hint string, firstTurn bool) string {
// question-asking convention plus — for the global chat — the conductor rule) to
// a prompt. firstTurn gates them — later turns already carry them via --resume.
// isGlobal is true only for the app-wide global chat (workspace==""); a project
// chat already runs inside a sandbox, so the conductor rule is skipped there.
func withContextHint(prompt, hint string, firstTurn, isGlobal bool) string {
if !firstTurn {
return prompt
}
prefix := chatQuestionGuidance + "\n\n"
if isGlobal {
prefix = chatConductorGuidance + "\n\n" + prefix
}
if hint != "" {
prefix = "[Context: " + hint + "]\n\n" + prefix
}
Expand Down Expand Up @@ -257,7 +281,7 @@ func (d *dashboardServer) runChatSession(w http.ResponseWriter, r *http.Request,
if hint == "" {
hint = contextHint
}
prompt := withContextHint(msg.Prompt, hint, sessionID == "")
prompt := withContextHint(msg.Prompt, hint, sessionID == "", workspace == "")

// Capture the user's prompt (the raw text, not the context-hinted wrapper)
// before the turn — the stream doesn't echo it back. Best-effort. This also
Expand Down
30 changes: 22 additions & 8 deletions internal/dashboard/chat_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,46 @@ import (
func TestWithContextHint(t *testing.T) {
hint := "The user is viewing repo acme/widget."

// First turn with a hint: the context marker is prepended, the question-asking
// guidance is included, and the original prompt is preserved.
got := withContextHint("what's broken?", hint, true)
// First turn, GLOBAL chat, with a hint: the context marker is prepended, the
// question-asking guidance and the conductor rule are included, and the
// original prompt is preserved.
got := withContextHint("what's broken?", hint, true, true)
if !strings.HasPrefix(got, "[Context: "+hint+"]") || !strings.Contains(got, "what's broken?") {
t.Errorf("first-turn hint not prepended: %q", got)
}
if !strings.Contains(got, "corral-question") {
t.Errorf("first-turn prompt should carry the question guidance: %q", got)
}
if !strings.Contains(got, "CONDUCTOR") || !strings.Contains(got, "/projects/create") {
t.Errorf("global first-turn prompt should carry the conductor/sandbox rule: %q", got)
}

// Later turns: nothing prepended (context + guidance already carried via
// --resume) — the prompt is passed through verbatim.
if got := withContextHint("and this one?", hint, false); got != "and this one?" {
if got := withContextHint("and this one?", hint, false, true); got != "and this one?" {
t.Errorf("later turn should be unchanged, got %q", got)
}
if got := withContextHint("hello", "", false); got != "hello" {
if got := withContextHint("hello", "", false, false); got != "hello" {
t.Errorf("later turn with no hint should be unchanged, got %q", got)
}

// First turn with NO context hint: no [Context:] marker, but the question
// guidance still applies (it's page-independent) and the prompt is preserved.
got = withContextHint("hello", "", true)
// First turn with NO context hint, GLOBAL: no [Context:] marker, but the
// question guidance + conductor rule still apply and the prompt is preserved.
got = withContextHint("hello", "", true, true)
if strings.Contains(got, "[Context:") {
t.Errorf("no-hint first turn should not have a context marker: %q", got)
}
if !strings.Contains(got, "corral-question") || !strings.HasSuffix(got, "hello") {
t.Errorf("no-hint first turn should carry guidance + the prompt: %q", got)
}

// First turn, PROJECT chat (isGlobal=false): it already runs inside a sandbox,
// so the conductor rule is NOT injected — but the question guidance still is.
got = withContextHint("fix the bug", hint, true, false)
if strings.Contains(got, "CONDUCTOR") {
t.Errorf("project chat should NOT get the conductor rule: %q", got)
}
if !strings.Contains(got, "corral-question") || !strings.HasSuffix(got, "fix the bug") {
t.Errorf("project first turn should still carry the question guidance + prompt: %q", got)
}
}
34 changes: 32 additions & 2 deletions internal/dashboard/webui/app-src/src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,36 @@ export function ChatPanel({
const curAssistantIdx = useRef<number | null>(null);
const lastToolIdx = useRef<number | null>(null);

// Stick-to-bottom autoscroll: only auto-scroll when the user is already at (or
// near) the bottom. If they've scrolled UP to read earlier output, a streaming
// frame must NOT yank them back down.
//
// stickRef is the intent, updated ONLY by real user scrolling (onLogScroll): at
// the bottom → stick=true; scrolled up → stick=false. scroll() re-affirms it
// synchronously (a programmatic jump we make ourselves shouldn't clear it) and
// bails when the user is reading above. Measured against the LIVE DOM each call
// so a growing transcript can't strand a stale value.
const stickRef = useRef(true);
const nearBottom = (el: HTMLDivElement) => el.scrollHeight - el.scrollTop - el.clientHeight < 60;
const programmaticScroll = useRef(false);
const onLogScroll = useCallback(() => {
// Ignore the scroll events our own autoscroll causes; only USER scrolls should
// change the stick intent.
if (programmaticScroll.current) return;
const el = logRef.current;
if (el) stickRef.current = nearBottom(el);
}, []);
const scroll = useCallback(() => {
const el = logRef.current;
if (!el || !stickRef.current) return; // reading above → leave them alone
requestAnimationFrame(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
if (!logRef.current) return;
programmaticScroll.current = true;
logRef.current.scrollTop = logRef.current.scrollHeight;
// Clear the guard after the resulting scroll event has fired.
requestAnimationFrame(() => {
programmaticScroll.current = false;
});
});
}, []);

Expand Down Expand Up @@ -275,6 +302,9 @@ export function ChatPanel({
setMsgs((m) => [...m, { role: "user", html: renderMarkdown(text) }]);
wsRef.current.send(JSON.stringify({ prompt: text, ctx: getCtx?.() || "" }));
setBusy(true);
// Sending your own message always follows it to the bottom, even if you'd
// scrolled up before.
stickRef.current = true;
scroll();
},
[getCtx, scroll],
Expand Down Expand Up @@ -344,7 +374,7 @@ export function ChatPanel({
</button>
)}
</div>
<div className="chat-log" id="log" ref={logRef}>
<div className="chat-log" id="log" ref={logRef} onScroll={onLogScroll}>
{msgs.map((m, i) => {
if (m.role === "meta")
return (
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/dashboard/webui/static/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/svg+xml" href="/static/app/favicon.svg">
<title>corral — control</title>
<script type="module" crossorigin src="/static/app/assets/index-BgodMaKm.js"></script>
<script type="module" crossorigin src="/static/app/assets/index-ClsO5wVY.js"></script>
<link rel="stylesheet" crossorigin href="/static/app/assets/index-RRSRH0xq.css">
</head>
<body>
Expand Down
Loading