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
3 changes: 3 additions & 0 deletions src/BorgDock.Tauri/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,9 @@ pub fn run() {
t3::commands::t3_thread_detail,
t3::commands::t3_first_user_messages,
t3::commands::t3_show_thread,
t3::commands::t3_respond,
t3::commands::t3_send_message,
t3::commands::t3_settle,
t3::commands::t3_open_thread,
t3::commands::t3_list_sessions,
// Azure DevOps HTTP proxy (CORS bypass)
Expand Down
101 changes: 101 additions & 0 deletions src/BorgDock.Tauri/src-tauri/src/t3/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,88 @@ pub async fn t3_open_thread(
})
}

async fn dispatch(client: &Client, command: Value) -> Result<(), String> {
client
.rpc("orchestration.dispatchCommand", command)
.await
.map(|_| ())
}

fn respond_command(thread_id: &str, request_id: &str, answers: Value) -> Value {
json!({
"type": "runtime-request.respond",
"threadId": thread_id,
"requestId": request_id,
"answers": answers,
})
}

fn message_command(thread_id: &str, text: &str) -> Value {
json!({
"type": "message.dispatch",
"threadId": thread_id,
"messageId": Uuid::new_v4().to_string(),
"text": text,
"attachments": [],
"createdBy": "user",
"creationSource": "web",
"dispatchMode": { "type": "start_immediately" },
"deliveryIntent": "auto",
})
}

/// Answer the questions an agent is waiting on. `answers` maps each question
/// id to the picked option's value, or to the text the user typed.
#[tauri::command]
pub async fn t3_respond(
host: HostRef,
thread_id: String,
request_id: String,
answers: HashMap<String, Value>,
) -> Result<(), String> {
let client = Client::connect(&host).await?;
dispatch(
&client,
respond_command(&thread_id, &request_id, json!(answers)),
)
.await
}

/// Send the agent a message and start its next turn. A thread in plan mode
/// stays read-only, so `leave_plan_mode` switches it back first.
#[tauri::command]
pub async fn t3_send_message(
host: HostRef,
thread_id: String,
text: String,
leave_plan_mode: bool,
) -> Result<(), String> {
let client = Client::connect(&host).await?;
if leave_plan_mode {
dispatch(
&client,
json!({
"type": "thread.interaction-mode.set",
"threadId": thread_id,
"interactionMode": "default",
}),
)
.await?;
}
dispatch(&client, message_command(&thread_id, &text)).await
}

/// Settle a thread: it is done, and its project is free for other work.
#[tauri::command]
pub async fn t3_settle(host: HostRef, thread_id: String) -> Result<(), String> {
let client = Client::connect(&host).await?;
dispatch(
&client,
json!({ "type": "thread.settle", "threadId": thread_id }),
)
.await
}

fn session_status(status: &str) -> &'static str {
match status {
"approval" => "waitingApproval",
Expand Down Expand Up @@ -496,6 +578,25 @@ mod tests {
assert!(issued_token("no session here").is_err());
}

#[test]
fn a_response_names_the_request_and_carries_the_answers() {
let command = respond_command("t", "req", json!({ "q": "Row id (Recommended)" }));
assert_eq!(command["type"], "runtime-request.respond");
assert_eq!(command["requestId"], "req");
assert_eq!(command["answers"]["q"], "Row id (Recommended)");
}

#[test]
fn a_message_starts_a_turn_and_has_every_field_t3_requires() {
let command = message_command("t", "Go ahead with the plan.");
assert_eq!(command["type"], "message.dispatch");
assert_eq!(command["attachments"], json!([]));
assert_eq!(command["createdBy"], "user");
assert_eq!(command["creationSource"], "web");
assert_eq!(command["dispatchMode"]["type"], "start_immediately");
assert!(command["messageId"].as_str().is_some());
}

#[test]
fn thread_paths_encode_the_id() {
assert_eq!(
Expand Down
2 changes: 2 additions & 0 deletions src/BorgDock.Tauri/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { SetupWizard } from '@/components/wizard/SetupWizard';
import { useAdoPolling } from '@/hooks/useAdoPolling';
import { useAutoUpdate } from '@/hooks/useAutoUpdate';
import { useCacheInit } from '@/hooks/useCacheInit';
import { useDispatchSync } from '@/hooks/useDispatchSync';
import { useExternalMergeCelebration } from '@/hooks/useExternalMergeCelebration';
import { useFlyoutSync } from '@/hooks/useFlyoutSync';
import { useGitHubPolling } from '@/hooks/useGitHubPolling';
Expand Down Expand Up @@ -204,6 +205,7 @@ export default function App() {
// Worktree branch mapping (for PR card badges)
useWorktreeMap(settings);
useT3Sessions(pollingEnabled);
useDispatchSync(pollingEnabled);

// Auto-update
useAutoUpdate(settings);
Expand Down
4 changes: 4 additions & 0 deletions src/BorgDock.Tauri/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ vi.mock('@/hooks/useAdoPolling', () => ({
useAdoPolling: vi.fn(),
}));

vi.mock('@/hooks/useDispatchSync', () => ({
useDispatchSync: vi.fn(),
}));

vi.mock('@/hooks/useAutoHide', () => ({
useAutoHide: vi.fn(),
}));
Expand Down
120 changes: 120 additions & 0 deletions src/BorgDock.Tauri/src/components/dispatch/AgentSays.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import clsx from 'clsx';
import { useState } from 'react';
import { Markdown } from '@/components/shared/Markdown';
import { Button } from '@/components/shared/primitives';
import type { AgentQuestion, AgentThread } from '@/services/dispatch/types';

/** Picked option index per question id; `CUSTOM` for the typed answer. */
type Picks = Record<string, number>;
const CUSTOM = -1;

/** The answers T3 expects: each question's picked option value, or the typed text. */
export function collectAnswers(
questions: readonly AgentQuestion[],
picks: Picks,
typed: Record<string, string>,
): Record<string, string> {
const answers: Record<string, string> = {};
for (const question of questions) {
const pick = picks[question.id] ?? question.recommended;
if (pick === null || pick === undefined) continue;
const value = pick === CUSTOM ? typed[question.id]?.trim() : question.options[pick]?.value;
if (value) answers[question.id] = value;
}
return answers;
}

interface QuestionsProps {
thread: AgentThread;
busy: boolean;
onAnswer: (answers: Record<string, string>) => void;
}

function Questions({ thread, busy, onAnswer }: QuestionsProps) {
const [picks, setPicks] = useState<Picks>({});
const [typed, setTyped] = useState<Record<string, string>>({});
const answers = collectAnswers(thread.questions, picks, typed);
const complete = Object.keys(answers).length === thread.questions.length;

return (
<div className="bd-dp-ask">
{thread.questions.map((question) => {
const pick = picks[question.id] ?? question.recommended;
return (
<fieldset key={question.id} className="bd-dp-ask__question">
<legend>{question.question}</legend>
{question.options.map((option, index) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={pick === index}
className={clsx('bd-dp-option', pick === index && 'bd-dp-option--on')}
onClick={() => setPicks({ ...picks, [question.id]: index })}
>
<span className="bd-dp-option__mark" aria-hidden="true" />
<span className="bd-dp-option__label">{option.label}</span>
{index === question.recommended && (
<span className="bd-dp-option__hint">Recommended</span>
)}
</button>
))}
{question.allowCustomAnswer && (
<button
type="button"
role="radio"
aria-checked={pick === CUSTOM}
className={clsx('bd-dp-option', pick === CUSTOM && 'bd-dp-option--on')}
onClick={() => setPicks({ ...picks, [question.id]: CUSTOM })}
>
<span className="bd-dp-option__mark" aria-hidden="true" />
<span className="bd-dp-option__label">Something else</span>
</button>
)}
{pick === CUSTOM && (
<input
className="bd-dp-ask__own"
aria-label={`Your answer to: ${question.question}`}
placeholder="Your answer"
value={typed[question.id] ?? ''}
onChange={(e) => setTyped({ ...typed, [question.id]: e.target.value })}
/>
)}
</fieldset>
);
})}
<Button
variant="primary"
size="md"
disabled={!complete || busy || !thread.available}
onClick={() => onAnswer(answers)}
>
{thread.questions.length > 1 ? 'Send answers' : 'Send answer'}
</Button>
</div>
);
}

interface AgentSaysProps {
thread: AgentThread;
busy?: boolean;
onAnswer: (answers: Record<string, string>) => void;
}

/** What the agent waits on you for: its questions, or the last thing it said. */
export function AgentSays({ thread, busy = false, onAnswer }: AgentSaysProps) {
if (thread.status === 'question' && thread.questions.length > 0) {
return (
<Questions key={thread.questionRequestId} thread={thread} busy={busy} onAnswer={onAnswer} />
);
}
if (thread.status === 'running') {
return <p className="bd-dp-says bd-dp-says--quiet">Working. Nothing to do.</p>;
}
if (!thread.lastAgentMessage) return null;
return (
<div className="bd-dp-says markdown-body" data-thread-status={thread.status}>
<Markdown allowRawHtml={false}>{thread.lastAgentMessage}</Markdown>
</div>
);
}
Loading
Loading