Compare commits

..
12 changed files with 4 additions and 535 deletions
+1 -2
View File
@@ -133,8 +133,7 @@ or a result you must retain.
Use the workspace picker before starting project-specific work. This gives the Use the workspace picker before starting project-specific work. This gives the
agent the right project context for file paths, shell commands, and session agent the right project context for file paths, shell commands, and session
metadata. A locally hosted WebUI opens the operating system's folder chooser metadata.
when one is available; remote deployments keep the manual absolute path entry.
Selecting a project does not replace the configured agent workspace. The two Selecting a project does not replace the configured agent workspace. The two
paths have different responsibilities: paths have different responsibilities:
@@ -3266,85 +3266,6 @@ async def _webui_mutate(
) )
@pytest.mark.asyncio
async def test_workspace_folder_picker_is_local_authenticated_mutation(
bus: MagicMock,
tmp_path: Path,
monkeypatch,
) -> None:
selected = tmp_path / "project"
selected.mkdir()
pick_folder = AsyncMock(return_value=str(selected))
monkeypatch.setattr(
"nanobot.webui.ws_http.native_folder_picker_available",
lambda: True,
)
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
channel = _ch(bus)
response = await _webui_mutate(channel, "workspace.pick_folder")
assert response.status_code == 200
assert response.json() == {"path": str(selected)}
pick_folder.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_workspace_folder_picker_rejects_direct_http(
bus: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pick_folder = AsyncMock(return_value="/tmp")
monkeypatch.setattr(
"nanobot.webui.ws_http.native_folder_picker_available",
lambda: True,
)
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
channel = _ch(bus)
response = await channel.gateway.http.dispatch(
_LOCAL,
_FakeReq(
{"Host": "127.0.0.1:8765"},
path="/api/workspaces/pick-folder",
),
)
assert response is not None
assert response.status_code == 405
assert b"authenticated WebSocket" in response.body
pick_folder.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("connection", "host"),
[(_REMOTE, "127.0.0.1"), (_LOCAL, "0.0.0.0")],
)
async def test_workspace_folder_picker_rejects_nonlocal_surfaces(
bus: MagicMock,
monkeypatch,
connection: _FakeConn,
host: str,
) -> None:
pick_folder = AsyncMock(return_value="/tmp")
monkeypatch.setattr(
"nanobot.webui.ws_http.native_folder_picker_available",
lambda: True,
)
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
channel = _ch(bus, host=host, token="test-token" if host == "0.0.0.0" else "")
response = await _webui_mutate(
channel,
"workspace.pick_folder",
connection=connection,
)
assert response.status_code == 403
pick_folder.assert_not_awaited()
def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None: def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
from nanobot.webui.http_utils import is_local_browser_request from nanobot.webui.http_utils import is_local_browser_request
-211
View File
@@ -1,211 +0,0 @@
"""Native directory picker used by a locally hosted WebUI."""
from __future__ import annotations
import asyncio
import os
import shutil
import sys
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
_PICKER_TIMEOUT_SECONDS = 300
_COMMON_ENV_KEYS = (
"HOME",
"LANG",
"LANGUAGE",
"LC_ALL",
"LC_CTYPE",
"LC_MESSAGES",
"LOGNAME",
"PATH",
"SHELL",
"TMPDIR",
"USER",
)
_LINUX_GUI_ENV_KEYS = (
"DBUS_SESSION_BUS_ADDRESS",
"DESKTOP_SESSION",
"DISPLAY",
"WAYLAND_DISPLAY",
"XAUTHORITY",
"XDG_CURRENT_DESKTOP",
"XDG_RUNTIME_DIR",
)
_MACOS_GUI_ENV_KEYS = ("SECURITYSESSIONID", "__CF_USER_TEXT_ENCODING")
_WINDOWS_GUI_ENV_KEYS = (
"APPDATA",
"COMSPEC",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"PATHEXT",
"ProgramData",
"ProgramFiles",
"ProgramFiles(x86)",
"ProgramW6432",
"SESSIONNAME",
"SYSTEMROOT",
"TEMP",
"TMP",
"USERDOMAIN",
"USERNAME",
"USERPROFILE",
)
class NativeFolderPickerError(RuntimeError):
"""Raised when an available native folder picker cannot complete."""
@dataclass(frozen=True)
class _PickerCommand:
argv: tuple[str, ...]
cancel_codes: frozenset[int]
cancel_markers: tuple[str, ...] = ()
def _picker_command() -> _PickerCommand | None:
if sys.platform == "darwin":
executable = shutil.which("osascript")
if executable is None:
return None
return _PickerCommand(
argv=(
executable,
"-e",
'set selectedFolder to choose folder with prompt "Select Workspace Directory"',
"-e",
"POSIX path of selectedFolder",
),
cancel_codes=frozenset({1}),
cancel_markers=("user canceled", "(-128)"),
)
if sys.platform == "win32":
executable = shutil.which("powershell.exe") or shutil.which("powershell")
if executable is None:
return None
script = (
"Add-Type -AssemblyName System.Windows.Forms;"
"$dialog=New-Object System.Windows.Forms.FolderBrowserDialog;"
"$dialog.Description='Select Workspace Directory';"
"$dialog.ShowNewFolderButton=$true;"
"if($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){"
"[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new();"
"[Console]::Out.Write($dialog.SelectedPath)}"
)
return _PickerCommand(
argv=(
executable,
"-NoProfile",
"-NonInteractive",
"-STA",
"-Command",
script,
),
cancel_codes=frozenset(),
)
if sys.platform.startswith("linux"):
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
return None
zenity = shutil.which("zenity")
if zenity is not None:
return _PickerCommand(
argv=(
zenity,
"--file-selection",
"--directory",
"--title=Select Workspace Directory",
),
cancel_codes=frozenset({1}),
)
kdialog = shutil.which("kdialog")
if kdialog is not None:
return _PickerCommand(
argv=(kdialog, "--getexistingdirectory", str(Path.home())),
cancel_codes=frozenset({1}),
)
return None
def native_folder_picker_available() -> bool:
"""Return whether this host can display a native directory picker."""
return _picker_command() is not None
def _picker_environment() -> dict[str, str]:
"""Pass only host UI/runtime variables, never provider or gateway secrets."""
keys: list[str] = list(_COMMON_ENV_KEYS)
if sys.platform == "darwin":
keys.extend(_MACOS_GUI_ENV_KEYS)
elif sys.platform == "win32":
keys.extend(_WINDOWS_GUI_ENV_KEYS)
elif sys.platform.startswith("linux"):
keys.extend(_LINUX_GUI_ENV_KEYS)
return {
key: value
for key in keys
if (value := os.environ.get(key)) is not None
}
async def _stop_process(process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
with suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=2)
except TimeoutError:
with suppress(ProcessLookupError):
process.kill()
await process.wait()
async def pick_native_folder() -> str | None:
"""Open the platform directory picker and return an existing absolute path."""
command = _picker_command()
if command is None:
raise NativeFolderPickerError("native folder picker is unavailable on this host")
try:
process = await asyncio.create_subprocess_exec(
*command.argv,
env=_picker_environment(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError as exc:
raise NativeFolderPickerError("native folder picker failed to start") from exc
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=_PICKER_TIMEOUT_SECONDS,
)
except asyncio.CancelledError:
await _stop_process(process)
raise
except TimeoutError as exc:
await _stop_process(process)
raise NativeFolderPickerError("native folder picker timed out") from exc
error_text = stderr.decode("utf-8", errors="replace").strip()
normalized_error = error_text.lower()
if process.returncode != 0:
if process.returncode in command.cancel_codes and (
not command.cancel_markers
or any(marker in normalized_error for marker in command.cancel_markers)
):
return None
raise NativeFolderPickerError("native folder picker failed")
selected = stdout.decode("utf-8", errors="replace").strip()
if not selected:
return None
path = Path(selected).expanduser()
if not path.is_absolute() or not path.is_dir():
raise NativeFolderPickerError("native folder picker returned an invalid directory")
return str(path)
+1 -9
View File
@@ -149,7 +149,6 @@ def workspaces_payload(
default_workspace: Path, default_workspace: Path,
default_restrict_to_workspace: bool, default_restrict_to_workspace: bool,
controls_available: bool, controls_available: bool,
folder_picker_available: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
default_access_mode = read_webui_default_access_mode() default_access_mode = read_webui_default_access_mode()
default_scope = ( default_scope = (
@@ -168,7 +167,6 @@ def workspaces_payload(
"controls": { "controls": {
"can_change_project": controls_available, "can_change_project": controls_available,
"can_use_full_access": controls_available, "can_use_full_access": controls_available,
"can_pick_folder": folder_picker_available,
}, },
} }
@@ -243,17 +241,11 @@ class WebUIWorkspaceController:
cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY)) cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY))
) )
def payload( def payload(self, *, controls_available: bool) -> dict[str, Any]:
self,
*,
controls_available: bool,
folder_picker_available: bool = False,
) -> dict[str, Any]:
return workspaces_payload( return workspaces_payload(
default_workspace=self._default_workspace, default_workspace=self._default_workspace,
default_restrict_to_workspace=self._default_restrict_to_workspace, default_restrict_to_workspace=self._default_restrict_to_workspace,
controls_available=controls_available, controls_available=controls_available,
folder_picker_available=folder_picker_available,
) )
def scope_from_envelope( def scope_from_envelope(
+1 -45
View File
@@ -62,7 +62,6 @@ from nanobot.webui.http_utils import (
from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import (
is_localhost as _is_localhost, is_localhost as _is_localhost,
) )
from nanobot.webui.http_utils import is_loopback_host as _is_loopback_host
from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import (
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request, is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
) )
@@ -86,11 +85,6 @@ from nanobot.webui.http_utils import (
) )
from nanobot.webui.ingress_policy import WebUIIngressPolicy from nanobot.webui.ingress_policy import WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.native_folder_picker import (
NativeFolderPickerError,
native_folder_picker_available,
pick_native_folder,
)
from nanobot.webui.session_automations import ( from nanobot.webui.session_automations import (
all_automations_payload, all_automations_payload,
serialize_automation_jobs, serialize_automation_jobs,
@@ -139,7 +133,6 @@ _WEBUI_MUTATION_PATHS = {
"skill.update": "/api/webui/skills/update", "skill.update": "/api/webui/skills/update",
"skill.delete": "/api/webui/skills/delete", "skill.delete": "/api/webui/skills/delete",
"sidebar.update": "/api/webui/sidebar-state/update", "sidebar.update": "/api/webui/sidebar-state/update",
"workspace.pick_folder": "/api/workspaces/pick-folder",
"settings.agent.update": "/api/settings/update", "settings.agent.update": "/api/settings/update",
"settings.model_configuration.create": "/api/settings/model-configurations/create", "settings.model_configuration.create": "/api/settings/model-configurations/create",
"settings.model_configuration.update": "/api/settings/model-configurations/update", "settings.model_configuration.update": "/api/settings/model-configurations/update",
@@ -336,7 +329,6 @@ class GatewayHTTPHandler:
) )
self.skill_state_action = skill_state_action self.skill_state_action = skill_state_action
self._skill_install_lock = asyncio.Lock() self._skill_install_lock = asyncio.Lock()
self._folder_picker_lock = asyncio.Lock()
self.cron_service = cron_service self.cron_service = cron_service
self.local_trigger_store = local_trigger_store self.local_trigger_store = local_trigger_store
self.cron_pending_job_ids = cron_pending_job_ids self.cron_pending_job_ids = cron_pending_job_ids
@@ -368,17 +360,6 @@ class GatewayHTTPHandler:
def workspace_controls_available(self, connection: Any) -> bool: def workspace_controls_available(self, connection: Any) -> bool:
return self._runtime_surface == "native" or _is_localhost(connection) return self._runtime_surface == "native" or _is_localhost(connection)
def workspace_folder_picker_available(
self,
connection: Any,
request: WsRequest,
) -> bool:
return (
_is_loopback_host(self.config.host)
and _is_local_browser_request(connection, request.headers)
and native_folder_picker_available()
)
# -- Token management --------------------------------------------------- # -- Token management ---------------------------------------------------
def check_api_token(self, request: WsRequest) -> bool: def check_api_token(self, request: WsRequest) -> bool:
@@ -454,7 +435,6 @@ class GatewayHTTPHandler:
"/api/webui/skills/update", "/api/webui/skills/update",
"/api/webui/skills/delete", "/api/webui/skills/delete",
"/api/webui/sidebar-state/update", "/api/webui/sidebar-state/update",
"/api/workspaces/pick-folder",
} }
@staticmethod @staticmethod
@@ -1074,8 +1054,6 @@ class GatewayHTTPHandler:
return await self._handle_sessions_list(request) return await self._handle_sessions_list(request)
if got == "/api/commands": if got == "/api/commands":
return self._handle_commands(request) return self._handle_commands(request)
if got == "/api/workspaces/pick-folder":
return await self._handle_workspace_folder_picker(connection, request)
if got == "/api/workspaces": if got == "/api/workspaces":
return self._handle_workspaces(connection, request) return self._handle_workspaces(connection, request)
if got == "/api/webui/skills/search": if got == "/api/webui/skills/search":
@@ -1111,32 +1089,10 @@ class GatewayHTTPHandler:
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
return _http_json_response( return _http_json_response(
self.workspaces.payload( self.workspaces.payload(
controls_available=self.workspace_controls_available(connection), controls_available=self.workspace_controls_available(connection)
folder_picker_available=self.workspace_folder_picker_available(
connection,
request,
),
) )
) )
async def _handle_workspace_folder_picker(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
if not self.workspace_folder_picker_available(connection, request):
return _http_error(403, "native folder picker is unavailable for this connection")
if self._folder_picker_lock.locked():
return _http_error(409, "native folder picker is already open")
try:
async with self._folder_picker_lock:
path = await pick_native_folder()
except NativeFolderPickerError as exc:
return _http_error(503, str(exc))
return _http_json_response({"path": path})
def _handle_webui_skills(self, request: WsRequest) -> Response: def _handle_webui_skills(self, request: WsRequest) -> Response:
if not self.check_api_token(request): if not self.check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
-112
View File
@@ -1,112 +0,0 @@
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from nanobot.webui import native_folder_picker as picker
def _picker_command(tmp_path: Path, body: str) -> picker._PickerCommand:
script = tmp_path / "picker.py"
script.write_text(f"{body}\n", encoding="utf-8")
return picker._PickerCommand((sys.executable, str(script)), frozenset({1}))
@pytest.mark.asyncio
async def test_pick_native_folder_returns_selected_directory(tmp_path, monkeypatch) -> None:
selected = tmp_path / "project"
selected.mkdir()
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
monkeypatch.setattr(
picker,
"_picker_command",
lambda: command,
)
assert await picker.pick_native_folder() == str(selected)
@pytest.mark.asyncio
async def test_pick_native_folder_uses_secret_free_environment(tmp_path, monkeypatch) -> None:
selected = tmp_path / "project"
selected.mkdir()
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
monkeypatch.setattr(picker, "_picker_command", lambda: command)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("OPENAI_API_KEY", "must-not-reach-picker")
original_spawn = picker.asyncio.create_subprocess_exec
captured_env: dict[str, str] = {}
async def capture_spawn(*args, **kwargs):
captured_env.update(kwargs["env"])
return await original_spawn(*args, **kwargs)
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", capture_spawn)
assert await picker.pick_native_folder() == str(selected)
assert captured_env["HOME"] == str(tmp_path)
assert "OPENAI_API_KEY" not in captured_env
def test_picker_environment_preserves_linux_display_context(monkeypatch) -> None:
monkeypatch.setattr(picker.sys, "platform", "linux")
monkeypatch.setenv("DISPLAY", ":42")
monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/test/bus")
monkeypatch.setenv("ANTHROPIC_API_KEY", "must-not-reach-picker")
environment = picker._picker_environment()
assert environment["DISPLAY"] == ":42"
assert environment["DBUS_SESSION_BUS_ADDRESS"] == "unix:path=/run/user/test/bus"
assert "ANTHROPIC_API_KEY" not in environment
@pytest.mark.asyncio
async def test_pick_native_folder_maps_dialog_cancel_to_none(tmp_path, monkeypatch) -> None:
command = _picker_command(tmp_path, "raise SystemExit(1)")
monkeypatch.setattr(
picker,
"_picker_command",
lambda: command,
)
assert await picker.pick_native_folder() is None
@pytest.mark.asyncio
async def test_pick_native_folder_rejects_non_directory_result(tmp_path, monkeypatch) -> None:
missing = tmp_path / "missing"
command = _picker_command(tmp_path, f"print({str(missing)!r}, end='')")
monkeypatch.setattr(
picker,
"_picker_command",
lambda: command,
)
with pytest.raises(picker.NativeFolderPickerError, match="invalid directory"):
await picker.pick_native_folder()
@pytest.mark.asyncio
async def test_pick_native_folder_reports_unavailable(monkeypatch) -> None:
monkeypatch.setattr(picker, "_picker_command", lambda: None)
assert picker.native_folder_picker_available() is False
with pytest.raises(picker.NativeFolderPickerError, match="unavailable"):
await picker.pick_native_folder()
@pytest.mark.asyncio
async def test_pick_native_folder_wraps_process_start_failure(tmp_path, monkeypatch) -> None:
command = _picker_command(tmp_path, "raise AssertionError('not started')")
monkeypatch.setattr(picker, "_picker_command", lambda: command)
async def fail_spawn(*args, **kwargs):
raise OSError("executable disappeared")
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", fail_spawn)
with pytest.raises(picker.NativeFolderPickerError, match="failed to start"):
await picker.pick_native_folder()
-17
View File
@@ -72,7 +72,6 @@ def test_workspace_payload_is_config_data_dir_scoped(tmp_path, monkeypatch) -> N
assert payload["default_scope"]["access_mode"] == "full" assert payload["default_scope"]["access_mode"] == "full"
assert payload["default_access_mode"] == "default" assert payload["default_access_mode"] == "default"
assert payload["controls"]["can_change_project"] is True assert payload["controls"]["can_change_project"] is True
assert payload["controls"]["can_pick_folder"] is False
def test_workspace_payload_hides_mutable_state_when_controls_unavailable( def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
@@ -92,22 +91,6 @@ def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
assert payload["default_scope"]["project_path"] == str(default.resolve()) assert payload["default_scope"]["project_path"] == str(default.resolve())
assert payload["controls"]["can_change_project"] is False assert payload["controls"]["can_change_project"] is False
assert payload["controls"]["can_use_full_access"] is False assert payload["controls"]["can_use_full_access"] is False
assert payload["controls"]["can_pick_folder"] is False
def test_workspace_payload_advertises_native_folder_picker(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
default = tmp_path / "default"
default.mkdir()
payload = workspaces_payload(
default_workspace=default,
default_restrict_to_workspace=False,
controls_available=True,
folder_picker_available=True,
)
assert payload["controls"]["can_pick_folder"] is True
def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None: def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
@@ -217,7 +217,6 @@ interface ThreadComposerProps {
workspaceControls?: WorkspacesPayload["controls"] | null; workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean; workspaceScopeDisabled?: boolean;
workspaceError?: string | null; workspaceError?: string | null;
onPickWorkspaceFolder?: () => Promise<string | null>;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
pendingQueueKey?: string | null; pendingQueueKey?: string | null;
transcriptionProvider?: string | null; transcriptionProvider?: string | null;
@@ -971,7 +970,6 @@ export function ThreadComposer({
workspaceControls = null, workspaceControls = null,
workspaceScopeDisabled = false, workspaceScopeDisabled = false,
workspaceError = null, workspaceError = null,
onPickWorkspaceFolder,
onWorkspaceScopeChange, onWorkspaceScopeChange,
pendingQueueKey = null, pendingQueueKey = null,
transcriptionProvider = null, transcriptionProvider = null,
@@ -2602,7 +2600,6 @@ export function ThreadComposer({
defaultScope={workspaceDefaultScope} defaultScope={workspaceDefaultScope}
controls={workspaceControls} controls={workspaceControls}
error={workspaceError} error={workspaceError}
onPickFolder={onPickWorkspaceFolder}
onChange={onWorkspaceScopeChange} onChange={onWorkspaceScopeChange}
/> />
</div> </div>
@@ -661,14 +661,6 @@ export function ThreadShell({
forkBoundaryMessageCount, forkBoundaryMessageCount,
} = useSessionHistory(historyKey); } = useSessionHistory(historyKey);
const { client, getToken, ingressLimits, modelName, token } = useClient(); const { client, getToken, ingressLimits, modelName, token } = useClient();
const pickWorkspaceFolder = useCallback(async (): Promise<string | null> => {
const response = await client.requestMutation<{ path: unknown }>(
"workspace.pick_folder",
{},
300_000,
);
return typeof response.path === "string" ? response.path : null;
}, [client]);
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null); const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
const [booting, setBooting] = useState(false); const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]); const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
@@ -1466,9 +1458,6 @@ export function ThreadShell({
workspaceControls={workspaceControls} workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled} workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError} workspaceError={workspaceError}
onPickWorkspaceFolder={
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
}
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={temporary ? null : chatId} pendingQueueKey={temporary ? null : chatId}
transcriptionProvider={settingsSnapshot?.transcription?.provider} transcriptionProvider={settingsSnapshot?.transcription?.provider}
@@ -1514,9 +1503,6 @@ export function ThreadShell({
workspaceControls={workspaceControls} workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled} workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError} workspaceError={workspaceError}
onPickWorkspaceFolder={
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
}
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
transcriptionProvider={settingsSnapshot?.transcription?.provider} transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits} ingressLimits={ingressLimits}
@@ -51,7 +51,6 @@ export function WorkspaceProjectPicker({
defaultScope, defaultScope,
controls, controls,
error, error,
onPickFolder,
onChange, onChange,
}: { }: {
isHero: boolean; isHero: boolean;
@@ -62,7 +61,6 @@ export function WorkspaceProjectPicker({
defaultScope: WorkspaceScopePayload | null; defaultScope: WorkspaceScopePayload | null;
controls: WorkspacesPayload["controls"] | null; controls: WorkspacesPayload["controls"] | null;
error?: string | null; error?: string | null;
onPickFolder?: () => Promise<string | null>;
onChange?: (scope: WorkspaceScopePayload) => void; onChange?: (scope: WorkspaceScopePayload) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -81,7 +79,7 @@ export function WorkspaceProjectPicker({
&& !!defaultScope && !!defaultScope
&& !!onChange && !!onChange
&& controls?.can_change_project !== false; && controls?.can_change_project !== false;
const pickFolder = getRuntimeHost().pickFolder ?? onPickFolder; const pickFolder = getRuntimeHost().pickFolder;
const nativeProjectPicker = !!pickFolder; const nativeProjectPicker = !!pickFolder;
useEffect(() => { useEffect(() => {
-1
View File
@@ -363,7 +363,6 @@ export interface WorkspacesPayload {
controls: { controls: {
can_change_project: boolean; can_change_project: boolean;
can_use_full_access: boolean; can_use_full_access: boolean;
can_pick_folder?: boolean;
}; };
} }
-39
View File
@@ -1260,45 +1260,6 @@ describe("ThreadComposer", () => {
})); }));
}); });
it("uses the gateway folder picker for a locally hosted WebUI", async () => {
const onWorkspaceScopeChange = vi.fn();
const pickFolder = vi.fn().mockResolvedValue("/Users/test/gateway-project");
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
access_mode: "full" as const,
restrict_to_workspace: false,
};
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{
can_change_project: true,
can_use_full_access: true,
can_pick_folder: true,
}}
onPickWorkspaceFolder={pickFolder}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
await waitFor(() => expect(pickFolder).toHaveBeenCalled());
expect(screen.queryByLabelText("Paste path")).not.toBeInTheDocument();
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
project_path: "/Users/test/gateway-project",
project_name: "gateway-project",
access_mode: "full",
restrict_to_workspace: false,
}));
});
it("uses the web path menu when no native host picker is available", async () => { it("uses the web path menu when no native host picker is available", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const defaultScope = { const defaultScope = {