mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
fix(desktop): harden workspace identity transitions
Co-authored-by: Wrench <911929dfb7f90834d50a0bdf1a24a05989d1078b93cc94edf9ad0db2fba683eb@buzz.block.builderlab.xyz> Signed-off-by: Wrench <911929dfb7f90834d50a0bdf1a24a05989d1078b93cc94edf9ad0db2fba683eb@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -2,14 +2,13 @@ use std::{
|
||||
collections::HashMap,
|
||||
io::Write,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU16, AtomicU8},
|
||||
atomic::{AtomicBool, AtomicU16, AtomicU64, AtomicU8},
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
use nostr::{Keys, ToBech32};
|
||||
use tauri::{AppHandle, Manager};
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use crate::huddle::HuddleState;
|
||||
@@ -35,6 +34,11 @@ pub struct AppState {
|
||||
/// Workspace-provided relay URL override. Set by `apply_workspace` on app
|
||||
/// init and takes priority over env vars and compile-time defaults.
|
||||
pub relay_url_override: Mutex<Option<String>>,
|
||||
/// Serializes workspace mutation transactions. Each caller also takes a
|
||||
/// generation ticket before queueing so an older queued apply fails closed
|
||||
/// rather than running after a newer workspace has won.
|
||||
pub workspace_apply_lock: AsyncMutex<()>,
|
||||
pub workspace_apply_generation: AtomicU64,
|
||||
/// Set during backend setup when managed agents are eligible for launch
|
||||
/// restore. `apply_workspace` consumes it after installing the workspace
|
||||
/// relay and identity, so agents never start against the fallback relay.
|
||||
@@ -206,6 +210,8 @@ pub fn build_app_state() -> AppState {
|
||||
header across origins (redirect-hop SSRF)",
|
||||
),
|
||||
relay_url_override: Mutex::new(None),
|
||||
workspace_apply_lock: AsyncMutex::new(()),
|
||||
workspace_apply_generation: AtomicU64::new(0),
|
||||
managed_agent_restore_pending: AtomicBool::new(false),
|
||||
managed_agent_profile_reconcile_enabled: AtomicBool::new(true),
|
||||
shutdown_started: AtomicBool::new(false),
|
||||
|
||||
@@ -206,9 +206,9 @@ pub(super) async fn start_local_agent_with_preflight(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
pubkey: &str,
|
||||
owner_hex: &str,
|
||||
allow_fresh_create_start: bool,
|
||||
expected_relay_url: Option<&str>,
|
||||
expected_signer_pubkey: Option<&str>,
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
let record_snapshot = {
|
||||
let _store_guard = state
|
||||
@@ -256,6 +256,11 @@ pub(super) async fn start_local_agent_with_preflight(
|
||||
expected_relay_url,
|
||||
crate::relay::relay_ws_url_with_override(state),
|
||||
)?;
|
||||
// Bind the active owner after the same final await as the relay. A
|
||||
// same-relay identity replacement during mesh preflight must not release
|
||||
// the stale preflight owner to spawn.
|
||||
let workspace_owner =
|
||||
crate::relay::bind_expected_signer(expected_signer_pubkey, workspace_owner_hex(state)?)?;
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
@@ -295,7 +300,7 @@ pub(super) async fn start_local_agent_with_preflight(
|
||||
app,
|
||||
record,
|
||||
&mut runtimes,
|
||||
Some(owner_hex),
|
||||
Some(workspace_owner.as_str()),
|
||||
&workspace_relay_url,
|
||||
)?;
|
||||
save_managed_agents(app, &records)?;
|
||||
@@ -411,10 +416,6 @@ pub async fn create_managed_agent(
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot the workspace owner pubkey for the legacy-record auth_tag
|
||||
// fallback. Computed outside the records lock to keep lock ordering simple.
|
||||
let owner_hex = workspace_owner_hex(&state)?;
|
||||
|
||||
// ── Phase 1: generate keys (sync lock) ────────────────────────────────────
|
||||
let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = {
|
||||
let _store_guard = state
|
||||
@@ -747,8 +748,7 @@ pub async fn create_managed_agent(
|
||||
// ── Phase 3b: local spawn (async preflight outside store lock) ───────────
|
||||
let mut spawn_error = None;
|
||||
let agent = if input.spawn_after_create && input.backend == BackendKind::Local {
|
||||
match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, true, None).await
|
||||
{
|
||||
match start_local_agent_with_preflight(&app, &state, &pubkey, true, None, None).await {
|
||||
Ok(agent) => agent,
|
||||
Err(error) => {
|
||||
let _store_guard = state
|
||||
@@ -957,9 +957,9 @@ pub async fn start_managed_agent(
|
||||
&app,
|
||||
&state,
|
||||
&pubkey,
|
||||
&owner_hex,
|
||||
false,
|
||||
expected_relay_url.as_deref(),
|
||||
expected_signer_pubkey.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -10,6 +10,23 @@ use crate::managed_agents::{
|
||||
};
|
||||
use crate::relay;
|
||||
|
||||
const WORKSPACE_APPLY_SUPERSEDED: &str = "workspace apply superseded by a newer request";
|
||||
|
||||
fn next_apply_generation(generation: &std::sync::atomic::AtomicU64) -> u64 {
|
||||
generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
|
||||
}
|
||||
|
||||
fn assert_current_apply_generation(
|
||||
generation: &std::sync::atomic::AtomicU64,
|
||||
ticket: u64,
|
||||
) -> Result<(), String> {
|
||||
if generation.load(Ordering::Acquire) == ticket {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WORKSPACE_APPLY_SUPERSEDED.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopt the pre-scoping global retention database's pending rows into `scope`.
|
||||
///
|
||||
/// Best-effort: a failure is logged and the boot proceeds. The migration's own
|
||||
@@ -131,11 +148,18 @@ pub async fn apply_workspace(
|
||||
agent_managed_profiles: Option<bool>,
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let state = app.state::<AppState>();
|
||||
let apply_generation = next_apply_generation(&state.workspace_apply_generation);
|
||||
let _apply_guard = state.workspace_apply_lock.lock().await;
|
||||
assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?;
|
||||
|
||||
let restore_app = app.clone();
|
||||
let apply_app = app.clone();
|
||||
// Capture the caller's relay before the blocking apply. Reading shared
|
||||
// state afterward could pick up a newer concurrent community switch.
|
||||
let profile_reconcile_relay = relay_url.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let app = apply_app;
|
||||
let state = app.state::<AppState>();
|
||||
|
||||
// ── Validate before mutating ──────────────────────────────────────────
|
||||
@@ -166,6 +190,11 @@ pub async fn apply_workspace(
|
||||
None => None,
|
||||
};
|
||||
|
||||
// A newer caller may have taken a generation ticket while this apply
|
||||
// was validating paths off-thread. Fail before the first mutation so
|
||||
// the stale apply cannot transiently overwrite the newer workspace.
|
||||
assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?;
|
||||
|
||||
// ── Apply all state changes (nothing below can fail) ──────────────────
|
||||
{
|
||||
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
|
||||
@@ -214,6 +243,8 @@ pub async fn apply_workspace(
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
|
||||
|
||||
assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?;
|
||||
|
||||
let state = restore_app.state::<AppState>();
|
||||
super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?;
|
||||
// The Bumble→Pollen migration may have renamed stopped agents. Reconcile
|
||||
@@ -316,5 +347,34 @@ pub async fn apply_workspace(
|
||||
});
|
||||
}
|
||||
|
||||
assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::AtomicU64;
|
||||
|
||||
use super::{assert_current_apply_generation, next_apply_generation};
|
||||
|
||||
#[test]
|
||||
fn newer_workspace_apply_supersedes_older_ticket_before_mutation() {
|
||||
let generation = AtomicU64::new(0);
|
||||
let delayed_a = next_apply_generation(&generation);
|
||||
let fast_b = next_apply_generation(&generation);
|
||||
|
||||
let error = assert_current_apply_generation(&generation, delayed_a).unwrap_err();
|
||||
assert!(error.contains("superseded"), "{error}");
|
||||
assert_current_apply_generation(&generation, fast_b).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_workspace_applies_each_hold_current_ticket() {
|
||||
let generation = AtomicU64::new(0);
|
||||
let first = next_apply_generation(&generation);
|
||||
assert_current_apply_generation(&generation, first).unwrap();
|
||||
let second = next_apply_generation(&generation);
|
||||
assert_current_apply_generation(&generation, second).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ pub fn relay_http_base_url(relay_url: &str) -> String {
|
||||
mod scope;
|
||||
pub use scope::{
|
||||
assert_expected_relay_scope, assert_expected_signer, bind_expected_relay_scope,
|
||||
ScopedWorkspaceRelay,
|
||||
bind_expected_signer, ScopedWorkspaceRelay,
|
||||
};
|
||||
|
||||
pub fn relay_api_base_url() -> String {
|
||||
|
||||
@@ -88,9 +88,36 @@ pub fn assert_expected_signer(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A workspace-signer read that has passed the caller-captured identity check.
|
||||
///
|
||||
/// The only constructor is [`bind_expected_signer`], so side effects consume
|
||||
/// the exact owner read that was validated rather than a stale pre-await value.
|
||||
#[derive(Debug)]
|
||||
pub struct ScopedWorkspaceSigner(String);
|
||||
|
||||
impl ScopedWorkspaceSigner {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a caller-captured signer against one active-owner read and bind
|
||||
/// that exact read for the side effect to consume. `None` preserves unscoped
|
||||
/// callers while still making the owner input single-read.
|
||||
pub fn bind_expected_signer(
|
||||
expected_signer_pubkey: Option<&str>,
|
||||
actual_signer_hex: String,
|
||||
) -> Result<ScopedWorkspaceSigner, String> {
|
||||
assert_expected_signer(expected_signer_pubkey, &actual_signer_hex)?;
|
||||
Ok(ScopedWorkspaceSigner(actual_signer_hex))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{assert_expected_relay_scope, assert_expected_signer, bind_expected_relay_scope};
|
||||
use super::{
|
||||
assert_expected_relay_scope, assert_expected_signer, bind_expected_relay_scope,
|
||||
bind_expected_signer,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn matching_scope_passes_across_ws_http_normalization() {
|
||||
@@ -183,6 +210,25 @@ mod tests {
|
||||
assert!(error.contains("active identity changed"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signer_bind_fails_closed_after_same_relay_identity_switch() {
|
||||
let captured = nostr::Keys::generate().public_key().to_hex();
|
||||
let switched = nostr::Keys::generate().public_key().to_hex();
|
||||
let error = bind_expected_signer(Some(&captured), switched).unwrap_err();
|
||||
assert!(error.contains("active identity changed"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signer_bind_returns_exact_read_for_scoped_and_unscoped_callers() {
|
||||
let actual = nostr::Keys::generate().public_key().to_hex();
|
||||
let scoped = bind_expected_signer(Some(&actual), actual.clone()).unwrap();
|
||||
assert_eq!(scoped.as_str(), actual);
|
||||
|
||||
let unscoped_actual = nostr::Keys::generate().public_key().to_hex();
|
||||
let unscoped = bind_expected_signer(None, unscoped_actual.clone()).unwrap();
|
||||
assert_eq!(unscoped.as_str(), unscoped_actual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_signer_preserves_unscoped_sends() {
|
||||
let hex = nostr::Keys::generate().public_key().to_hex();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./projectAgentConversation.ts";
|
||||
import {
|
||||
clearStoredProjectsAgentConversation,
|
||||
projectsConversationScope,
|
||||
readStoredProjectsAgentConversation,
|
||||
writeStoredProjectsAgentConversation,
|
||||
} from "./projectAgentConversationStorage.ts";
|
||||
@@ -51,6 +52,50 @@ globalThis.localStorage = {
|
||||
|
||||
beforeEach(() => store.clear());
|
||||
|
||||
test("conversation scopes isolate same-relay identities and restore on return", () => {
|
||||
const relay = "wss://relay.example.com";
|
||||
const resource = "30617:owner:buzz";
|
||||
const identityA = "a".repeat(64);
|
||||
const identityB = "b".repeat(64);
|
||||
const scopeA = projectsConversationScope(
|
||||
"detail",
|
||||
relay,
|
||||
identityA,
|
||||
resource,
|
||||
);
|
||||
const scopeB = projectsConversationScope(
|
||||
"detail",
|
||||
relay,
|
||||
identityB,
|
||||
resource,
|
||||
);
|
||||
assert.notEqual(scopeA, scopeB);
|
||||
|
||||
const pointer = {
|
||||
agentPubkey: AGENT_PUBKEY,
|
||||
channelId: EXISTING_DM.id,
|
||||
opener: OPENER,
|
||||
};
|
||||
writeStoredProjectsAgentConversation(scopeA, pointer);
|
||||
assert.equal(readStoredProjectsAgentConversation(scopeB), null);
|
||||
assert.deepEqual(readStoredProjectsAgentConversation(scopeA), pointer);
|
||||
});
|
||||
|
||||
test("conversation scopes fail closed without relay, signer, or resource", () => {
|
||||
assert.equal(
|
||||
projectsConversationScope("detail", null, SELF_PUBKEY, "repo"),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
projectsConversationScope("detail", WORKSPACE_ID, null, "repo"),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
projectsConversationScope("detail", WORKSPACE_ID, SELF_PUBKEY, ""),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("an existing agent DM is never auto-restored without a stored pointer", () => {
|
||||
const restored = restoreProjectsAgentConversation({
|
||||
stored: null,
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
const CONVERSATION_STORAGE_PREFIX = "buzz.projects.agentConversation";
|
||||
|
||||
/** Builds the identity boundary for Projects conversation pointers and drafts. */
|
||||
export function projectsConversationScope(
|
||||
surface: string,
|
||||
relayUrl: string | null,
|
||||
signerPubkey: string | null,
|
||||
resource: string,
|
||||
): string | null {
|
||||
if (!relayUrl || !signerPubkey || !resource) return null;
|
||||
return `${surface}:${relayUrl}:${signerPubkey.toLowerCase()}:${resource}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact opening prompt of an inline Projects conversation, identified by
|
||||
* the signed event the relay accepted. `createdAt` alone (epoch seconds)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import {
|
||||
clearStoredProjectsAgentConversation,
|
||||
type ProjectsConversationOpener,
|
||||
projectsConversationScope,
|
||||
readStoredProjectsAgentConversation,
|
||||
type StoredProjectsAgentConversation,
|
||||
writeStoredProjectsAgentConversation,
|
||||
@@ -57,6 +58,7 @@ export function ProjectAgentChatPanel({
|
||||
widthPx: number;
|
||||
}) {
|
||||
const { activeCommunity } = useCommunities();
|
||||
const identityQuery = useIdentityQuery();
|
||||
// Repository coordinates (`kind:owner:dtag`) are not globally unique — the
|
||||
// same address can exist on two relays. Scope persistence, drafts, and
|
||||
// restore to the community's relay identity so a community switch can
|
||||
@@ -66,9 +68,15 @@ export function ProjectAgentChatPanel({
|
||||
const relayScope = activeCommunity?.relayUrl
|
||||
? normalizeRelayUrl(activeCommunity.relayUrl)
|
||||
: null;
|
||||
const storageScope = relayScope
|
||||
? `detail:${relayScope}:${context.repoAddress}`
|
||||
const signerScope = identityQuery.data?.pubkey
|
||||
? normalizePubkey(identityQuery.data.pubkey)
|
||||
: null;
|
||||
const storageScope = projectsConversationScope(
|
||||
"detail",
|
||||
relayScope,
|
||||
signerScope,
|
||||
context.repoAddress,
|
||||
);
|
||||
const [isSending, setIsSending] = React.useState(false);
|
||||
const [storedConversation, setStoredConversation] =
|
||||
React.useState<StoredProjectsAgentConversation | null>(() =>
|
||||
@@ -78,7 +86,6 @@ export function ProjectAgentChatPanel({
|
||||
React.useState<ProjectAgentConversation | null>(null);
|
||||
const candidates = useAgentCandidates();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const profileQuery = useProfileQuery();
|
||||
const openDmMutation = useOpenDmMutation();
|
||||
const startAgentMutation = useStartManagedAgentMutation();
|
||||
|
||||
@@ -2,6 +2,8 @@ import type * as React from "react";
|
||||
|
||||
import { normalizeRelayUrl } from "@/features/communities/communityStorage";
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
|
||||
import { ProjectAgentChatPanel } from "./ProjectAgentChatPanel";
|
||||
import { ProjectRepositoryActionsPanel } from "./ProjectRepositoryActionsPanel";
|
||||
@@ -24,6 +26,7 @@ export function ProjectDetailRightPanel({
|
||||
sharedHeaderBackdrop?: boolean;
|
||||
}) {
|
||||
const { activeCommunity } = useCommunities();
|
||||
const identityQuery = useIdentityQuery();
|
||||
if (mode === "chat") {
|
||||
// Remount on community identity as well as repository address: the same
|
||||
// repo coordinate can exist in two communities, and retained panel state
|
||||
@@ -31,11 +34,14 @@ export function ProjectDetailRightPanel({
|
||||
const relayScope = activeCommunity?.relayUrl
|
||||
? normalizeRelayUrl(activeCommunity.relayUrl)
|
||||
: "";
|
||||
const signerScope = identityQuery.data?.pubkey
|
||||
? normalizePubkey(identityQuery.data.pubkey)
|
||||
: "";
|
||||
return (
|
||||
<ProjectAgentChatPanel
|
||||
canResetWidth={repositoryProps.canResetWidth}
|
||||
context={context}
|
||||
key={`${relayScope}:${context.repoAddress}`}
|
||||
key={`${relayScope}:${signerScope}:${context.repoAddress}`}
|
||||
onResetWidth={repositoryProps.onResetWidth}
|
||||
onResizeStart={repositoryProps.onResizeStart}
|
||||
sharedHeaderBackdrop={sharedHeaderBackdrop}
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import {
|
||||
clearStoredProjectsAgentConversation,
|
||||
type ProjectsConversationOpener,
|
||||
projectsConversationScope,
|
||||
readStoredProjectsAgentConversation,
|
||||
type StoredProjectsAgentConversation,
|
||||
writeStoredProjectsAgentConversation,
|
||||
@@ -363,9 +364,7 @@ export function ProjectsAgentPromptPage({
|
||||
}) {
|
||||
const [prompt, setPrompt] = React.useState("");
|
||||
const [storedConversation, setStoredConversation] =
|
||||
React.useState<StoredProjectsAgentConversation | null>(() =>
|
||||
readStoredProjectsAgentConversation(workspaceId),
|
||||
);
|
||||
React.useState<StoredProjectsAgentConversation | null>(null);
|
||||
const [selectedPubkey, setSelectedPubkey] = React.useState<string | null>(
|
||||
() => storedConversation?.agentPubkey ?? null,
|
||||
);
|
||||
@@ -396,6 +395,15 @@ export function ProjectsAgentPromptPage({
|
||||
const relayScope = activeCommunity?.relayUrl
|
||||
? normalizeRelayUrl(activeCommunity.relayUrl)
|
||||
: null;
|
||||
const signerScope = identityQuery.data?.pubkey
|
||||
? normalizePubkey(identityQuery.data.pubkey)
|
||||
: null;
|
||||
const scopedWorkspaceId = projectsConversationScope(
|
||||
"workspace",
|
||||
relayScope,
|
||||
signerScope,
|
||||
workspaceId ?? "",
|
||||
);
|
||||
|
||||
const candidatePubkeys = React.useMemo(
|
||||
() => candidates.map((candidate) => candidate.pubkey),
|
||||
@@ -454,6 +462,15 @@ export function ProjectsAgentPromptPage({
|
||||
onLinkSelectionChangeRef.current = linkEditor.showFromCursor;
|
||||
onLinkShortcutRef.current = linkEditor.openFromShortcut;
|
||||
|
||||
React.useEffect(() => {
|
||||
const stored = readStoredProjectsAgentConversation(scopedWorkspaceId);
|
||||
setStoredConversation(stored);
|
||||
setConversation(null);
|
||||
setSelectedPubkey(stored?.agentPubkey ?? null);
|
||||
setPrompt("");
|
||||
richText.clearContent();
|
||||
}, [richText.clearContent, scopedWorkspaceId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!richText.editor) return;
|
||||
const frame = window.requestAnimationFrame(richText.focusPreserve);
|
||||
@@ -532,7 +549,7 @@ export function ProjectsAgentPromptPage({
|
||||
};
|
||||
setConversation(nextConversation);
|
||||
setStoredConversation(stored);
|
||||
writeStoredProjectsAgentConversation(workspaceId, stored);
|
||||
writeStoredProjectsAgentConversation(scopedWorkspaceId, stored);
|
||||
}
|
||||
setPrompt("");
|
||||
richText.clearContent();
|
||||
@@ -554,20 +571,20 @@ export function ProjectsAgentPromptPage({
|
||||
richText.getMarkdown,
|
||||
selectedAgent,
|
||||
startAgentMutation,
|
||||
workspaceId,
|
||||
scopedWorkspaceId,
|
||||
]);
|
||||
submitPromptRef.current = () => {
|
||||
void handleSubmit();
|
||||
};
|
||||
|
||||
const handleClearConversation = React.useCallback(() => {
|
||||
clearStoredProjectsAgentConversation(workspaceId);
|
||||
clearStoredProjectsAgentConversation(scopedWorkspaceId);
|
||||
setStoredConversation(null);
|
||||
setConversation(null);
|
||||
setSelectedPubkey(null);
|
||||
setPrompt("");
|
||||
richText.clearContent();
|
||||
}, [richText.clearContent, workspaceId]);
|
||||
}, [richText.clearContent, scopedWorkspaceId]);
|
||||
|
||||
const promptBox = (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user