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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ services/local-service/internal/rpc/workspace/

apps/.temp/*
!apps/.temp/.gitkeep
.sisyphus/
2 changes: 2 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@
"ahooks": "^3.9.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.468.0",
"motion": "^11.15.0",
"react": "^18.3.1",
"react-day-picker": "^10.0.0",
"react-dom": "^18.3.1",
"react-fast-compare": "^3.2.2",
"react-router-dom": "^7.14.0",
Expand Down
57 changes: 56 additions & 1 deletion apps/desktop/src-tauri/scripts/runWithSidecar.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
/* global process, console */
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildLocalServiceSidecar } from "./ensureLocalServiceSidecar.mjs";

const currentDirectory = dirname(fileURLToPath(import.meta.url));

function escapePowerShellLiteral(value) {
return value.replace(/'/g, "''");
}

function buildStopStaleBundledSidecarsCommand(targetRoot) {
const escapedTargetRoot = escapePowerShellLiteral(targetRoot);
return [
`$targetRoot = ([System.IO.Path]::GetFullPath('${escapedTargetRoot}')).TrimEnd('\\') + '\\'`,
"Get-CimInstance Win32_Process | Where-Object {",
" $executablePath = $_.ExecutablePath",
" if (-not $executablePath) { return $false }",
" $fullPath = [System.IO.Path]::GetFullPath($executablePath)",
" $fileName = [System.IO.Path]::GetFileName($fullPath)",
" $fullPath.StartsWith($targetRoot, [System.StringComparison]::OrdinalIgnoreCase) -and $fileName.StartsWith('cialloclaw-service', [System.StringComparison]::OrdinalIgnoreCase)",
"} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
].join("; ");
}

function resolveCommand(name) {
return process.platform === "win32" && name === "corepack" ? "corepack.cmd" : name;
}
Expand Down Expand Up @@ -36,6 +54,42 @@ function runFrontendCommand(commandName) {
});
}

function stopStaleBundledSidecars() {
if (process.platform !== "win32") {
return;
}

const staleSidecarTargetRoot = resolve(currentDirectory, "..", "target");

// Tauri copies the bundled sidecar into `src-tauri/target/*` before booting
// the app. On Windows, a stale child keeps that copied executable locked and
// the next build panics with `PermissionDenied` while refreshing the bundle.
// Only terminate copied sidecars for the current workspace target directory.
const result = spawnSync(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-Command",
buildStopStaleBundledSidecarsCommand(staleSidecarTargetRoot),
],
{
stdio: "pipe",
encoding: "utf8",
},
);

if (result.error) {
console.warn("Failed to stop stale bundled sidecars before launching Tauri.");
return;
}

if (result.status !== 0) {
const details = result.stderr?.trim() || result.stdout?.trim();
console.warn(`Failed to stop stale bundled sidecars before launching Tauri.${details ? ` ${details}` : ""}`);
}
}

const commandName = process.argv[2];

if (commandName !== "dev" && commandName !== "build") {
Expand All @@ -44,6 +98,7 @@ if (commandName !== "dev" && commandName !== "build") {
}

try {
stopStaleBundledSidecars();
const sidecarPath = buildLocalServiceSidecar();
console.log(`Prepared local-service sidecar: ${sidecarPath}`);
} catch (error) {
Expand Down
123 changes: 116 additions & 7 deletions apps/desktop/src-tauri/src/selection/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ static SHELL_BALL_SELECTION_MONITOR_STATE: Lazy<Mutex<SelectionMonitorState>> =

#[derive(Default)]
struct SelectionMonitorState {
invalidated_fingerprint: Option<String>,
last_fingerprint: Option<String>,
probe_pending: bool,
}
Expand Down Expand Up @@ -132,6 +133,13 @@ pub fn install_selection_listener(app: &AppHandle) -> Result<(), String> {
/// a shell-ball selection snapshot.
pub fn read_selection_snapshot(
app: &AppHandle,
) -> Result<Option<SelectionSnapshotPayload>, String> {
let snapshot = read_selection_snapshot_raw(app)?;
Ok(suppress_invalidated_selection_snapshot(snapshot))
}

fn read_selection_snapshot_raw(
app: &AppHandle,
) -> Result<Option<SelectionSnapshotPayload>, String> {
let _com_guard = ComGuard::initialize()?;
let foreground_window = unsafe { GetForegroundWindow() };
Expand Down Expand Up @@ -174,6 +182,22 @@ pub fn read_selection_snapshot(
)))
}

fn suppress_invalidated_selection_snapshot(
snapshot: Option<SelectionSnapshotPayload>,
) -> Option<SelectionSnapshotPayload> {
let fingerprint = selection_snapshot_fingerprint(snapshot.as_ref());
let invalidated_fingerprint = SHELL_BALL_SELECTION_MONITOR_STATE
.lock()
.ok()
.and_then(|state| state.invalidated_fingerprint.clone());

if fingerprint.is_some() && fingerprint == invalidated_fingerprint {
return None;
}

snapshot
}

unsafe extern "system" fn shell_ball_selection_mouse_hook(
n_code: i32,
w_param: WPARAM,
Expand All @@ -183,6 +207,7 @@ unsafe extern "system" fn shell_ball_selection_mouse_hook(
// left click will re-probe the selection and clear shell-ball alert state
// if the user no longer has a live selection.
if n_code >= 0 && w_param.0 as u32 == WM_LBUTTONUP {
clear_invalidated_selection_fingerprint();
schedule_selection_probe(SHELL_BALL_SELECTION_MOUSE_DELAY_MS);
}

Expand All @@ -196,6 +221,12 @@ unsafe extern "system" fn shell_ball_selection_keyboard_hook(
) -> LRESULT {
if n_code >= 0 && (w_param.0 as u32 == WM_KEYDOWN || w_param.0 as u32 == WM_SYSKEYDOWN) {
let keyboard_info = *(l_param.0 as *const KBDLLHOOKSTRUCT);
if should_invalidate_selection_from_key_event(keyboard_info.vkCode) {
invalidate_current_selection();
} else if should_clear_selection_invalidation_from_key_event(keyboard_info.vkCode) {
clear_invalidated_selection_fingerprint();
}

if should_probe_selection_from_key_event(keyboard_info.vkCode) {
schedule_selection_probe(SHELL_BALL_SELECTION_KEYBOARD_DELAY_MS);
}
Expand All @@ -216,6 +247,10 @@ fn should_probe_selection_from_key_event(vk_code: u32) -> bool {
return true;
}

if ctrl_down && vk_code == b'X' as u32 {
return true;
}

if !shift_down {
return false;
}
Expand All @@ -233,6 +268,76 @@ fn should_probe_selection_from_key_event(vk_code: u32) -> bool {
)
}

fn should_invalidate_selection_from_key_event(vk_code: u32) -> bool {
let ctrl_down = unsafe { (GetAsyncKeyState(VK_CONTROL.0 as i32) as u16 & 0x8000) != 0 };
ctrl_down && vk_code == b'X' as u32
}

fn should_clear_selection_invalidation_from_key_event(vk_code: u32) -> bool {
let ctrl_down = unsafe { (GetAsyncKeyState(VK_CONTROL.0 as i32) as u16 & 0x8000) != 0 };
let shift_down = unsafe { (GetAsyncKeyState(VK_SHIFT.0 as i32) as u16 & 0x8000) != 0 };

if ctrl_down && vk_code == b'A' as u32 {
return true;
}

if !shift_down {
return false;
}

matches!(
vk_code,
code if code == VK_LEFT.0 as u32
|| code == VK_RIGHT.0 as u32
|| code == VK_UP.0 as u32
|| code == VK_DOWN.0 as u32
|| code == VK_HOME.0 as u32
|| code == VK_END.0 as u32
|| code == VK_PRIOR.0 as u32
|| code == VK_NEXT.0 as u32
)
}

fn invalidate_current_selection() {
thread::spawn(move || {
let Some(app) = SHELL_BALL_SELECTION_APP_HANDLE
.lock()
.ok()
.and_then(|guard| guard.as_ref().cloned())
else {
return;
};

let snapshot = read_selection_snapshot_raw(&app).ok().flatten();
let fingerprint = selection_snapshot_fingerprint(snapshot.as_ref());

let should_emit = {
let mut state = match SHELL_BALL_SELECTION_MONITOR_STATE.lock() {
Ok(guard) => guard,
Err(_) => return,
};

state.invalidated_fingerprint = fingerprint;
if state.last_fingerprint.is_none() {
false
} else {
state.last_fingerprint = None;
true
}
};

if should_emit {
emit_selection_snapshot(&app, None);
}
});
}

fn clear_invalidated_selection_fingerprint() {
if let Ok(mut state) = SHELL_BALL_SELECTION_MONITOR_STATE.lock() {
state.invalidated_fingerprint = None;
}
}

fn schedule_selection_probe(delay_ms: u64) {
{
let mut state = match SHELL_BALL_SELECTION_MONITOR_STATE.lock() {
Expand Down Expand Up @@ -281,16 +386,20 @@ fn schedule_selection_probe(delay_ms: u64) {
return;
}

let _ = app.emit_to(
"shell-ball",
SHELL_BALL_SELECTION_SNAPSHOT_EVENT,
serde_json::json!({
"snapshot": snapshot,
}),
);
emit_selection_snapshot(&app, snapshot);
});
}

fn emit_selection_snapshot(app: &AppHandle, snapshot: Option<SelectionSnapshotPayload>) {
let _ = app.emit_to(
"shell-ball",
SHELL_BALL_SELECTION_SNAPSHOT_EVENT,
serde_json::json!({
"snapshot": snapshot,
}),
);
}

fn reset_probe_pending() {
if let Ok(mut state) = SHELL_BALL_SELECTION_MONITOR_STATE.lock() {
state.probe_pending = false;
Expand Down
49 changes: 24 additions & 25 deletions apps/desktop/src/app/dashboard/DashboardRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { HashRouter, Link, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
Expand All @@ -11,6 +10,7 @@ import {
import { MemoryPage } from "@/features/dashboard/memory/MemoryPage";
import { NotesPage } from "@/features/dashboard/notes/NotesPage";
import { SafetyPage } from "@/features/dashboard/safety/SafetyPage";
import { DashboardModuleFloatingNav } from "@/features/dashboard/shared/DashboardModuleFloatingNav";
import {
dashboardTaskDetailNavigationEvent,
navigateToDashboardTaskDetail,
Expand All @@ -21,6 +21,7 @@ import {
useDashboardEscapeCoordinator,
useDashboardEscapeHandler,
} from "@/features/dashboard/shared/dashboardEscapeCoordinator";
import { dashboardModules } from "@/features/dashboard/shared/dashboardRoutes";
import { resolveDashboardModuleRoutePath, resolveDashboardRoutePath } from "@/features/dashboard/shared/dashboardRouteTargets";
import {
dashboardTaskDeliveryNavigationEvent,
Expand Down Expand Up @@ -318,31 +319,29 @@ function DashboardRoutes() {
/>
);

const activeSharedModule = useMemo(
() => dashboardModules.find((module) => location.pathname === module.path || location.pathname.startsWith(`${module.path}/`)) ?? null,
[location.pathname],
);

return (
<div className={cn("dashboard-app", isOpening && "is-opening")}>
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
animate={{ opacity: 1, scale: 1, y: 0 }}
className="dashboard-route-layer"
exit={{ opacity: 0, scale: 0.988, y: -16 }}
initial={{ opacity: 0, scale: 0.958, y: 30 }}
style={{ transformOrigin: "50% 53.2%" }}
transition={{ duration: 0.46, ease: [0.22, 1, 0.36, 1] }}
>
<Routes location={location}>
<Route
element={dashboardHomeRoute}
path={resolveDashboardRoutePath("home")}
/>
<Route element={<TasksPage />} path={`${resolveDashboardModuleRoutePath("tasks")}/*`} />
<Route element={<NotesPage />} path={`${resolveDashboardModuleRoutePath("notes")}/*`} />
<Route element={<MemoryPage />} path={`${resolveDashboardModuleRoutePath("memory")}/*`} />
<Route element={<SafetyPage />} path={`${resolveDashboardModuleRoutePath("safety")}/*`} />
<Route element={<Navigate replace to={resolveDashboardRoutePath("home")} />} path="*" />
</Routes>
</motion.div>
</AnimatePresence>
{activeSharedModule ? (
<DashboardModuleFloatingNav accentColor={activeSharedModule.accent} includeHomeLink />
) : null}
<div className={cn("dashboard-route-layer", activeSharedModule && "dashboard-route-layer--with-shared-topbar")}>
<Routes location={location}>
<Route
element={dashboardHomeRoute}
path={resolveDashboardRoutePath("home")}
/>
<Route element={<TasksPage />} path={`${resolveDashboardModuleRoutePath("tasks")}/*`} />
<Route element={<NotesPage />} path={`${resolveDashboardModuleRoutePath("notes")}/*`} />
<Route element={<MemoryPage />} path={`${resolveDashboardModuleRoutePath("memory")}/*`} />
<Route element={<SafetyPage />} path={`${resolveDashboardModuleRoutePath("safety")}/*`} />
<Route element={<Navigate replace to={resolveDashboardRoutePath("home")} />} path="*" />
</Routes>
</div>
<DashboardVoiceField
isOpen={voiceOpen}
onClose={() => setVoiceOpen(false)}
Expand Down
Loading
Loading