feat: enable remote SDK and resilient device orchestration

This commit is contained in:
somew1nd committed 2026-08-30 00:41:07 -07:00
1 parent 3cc711df51
commit ddfa420945
110 files changed
+14450 -12954

No files matched your search

@@ -674,15 +674,24 @@
<div class="step-title-row">
<h3 class="step-title">Android Device & Emulator Connection</h3>
</div>
<p class="step-desc">Physical Android phone or local Android Virtual Device (AVD)</p>
<p class="step-desc">Local emulator, attached phone, wireless device, or another ADB server</p>
</div>
<button
type="button"
class="btn-restart-adb btn-connection-options"
(click)="toggleConnectionMethods()"
>
<span class="material-symbols-outlined">swap_horiz</span>
<span>{{ showConnectionMethods() ? 'Hide Options' : 'Change Connection' }}</span>
</button>
<button
type="button"
class="btn-restart-adb"
[disabled]="isRestartingAdb()"
(click)="restartAdbServer()"
title="Restart local ADB daemon"
[title]="isRemoteAdbServer() ? 'Refresh devices without stopping the remote ADB server' : 'Restart local ADB daemon'"
>
@if (isRestartingAdb()) {
<span class="material-symbols-outlined spinning">sync</span>
@@ -692,7 +701,7 @@
<span class="feedback-text">{{ adbRestartFeedback() }}</span>
} @else {
<span class="material-symbols-outlined">sync</span>
<span>Restart ADB</span>
<span>{{ isRemoteAdbServer() ? 'Refresh Devices' : 'Restart ADB' }}</span>
}
</button>
</div>
@@ -726,6 +735,12 @@
<span class="meta-dot">·</span>
<span>Android {{ activeDevice()?.android_version }}</span>
}
@if (isRemoteAdbServer()) {
<span class="meta-dot">·</span>
<span class="remote-endpoint-label">
Remote via {{ adbServerStatus()?.endpoint?.host }}:{{ adbServerStatus()?.endpoint?.port }}
</span>
}
</div>
</div>
<div class="device-hero-status">
@@ -919,7 +934,7 @@
<div class="failed-actions-bar">
@if (emulatorLaunchState()?.avd_name) {
<button type="button" class="btn-retry-launch" (click)="launchAvdEmulator(emulatorLaunchState()!.avd_name!)">
<button type="button" class="btn-retry-launch" [disabled]="isRemoteAdbServer()" (click)="launchAvdEmulator(emulatorLaunchState()!.avd_name!)">
<span class="material-symbols-outlined">refresh</span>
<span>Retry Launch ({{ emulatorLaunchState()?.avd_name }})</span>
</button>
@@ -937,7 +952,7 @@
}
<!-- STATE B-3: NO DEVICE DETECTED / READY TO CONNECT GUIDE -->
@if (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.summary !== 'Device Locked' && adbProbe()?.summary !== 'Lock State Unknown' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed') {
@if (showConnectionMethods() || (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.summary !== 'Device Locked' && adbProbe()?.summary !== 'Lock State Unknown' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed')) {
<div class="foolproof-guide-container">
<!-- Method Selection Nav Cards -->
<div class="connection-methods-nav">
@@ -954,13 +969,14 @@
<div class="method-info">
<div class="method-title-row">
<span class="method-title">Android Emulator</span>
@if (installedAvds().length > 0) {
<span class="method-badge-pill detected">{{ installedAvds().length }} DETECTED</span>
} @else {
<span class="method-badge-pill recommended">RECOMMENDED</span>
}
</div>
<span class="method-desc">Launch local Android Virtual Device</span>
<span class="method-desc">
@if (installedAvds().length > 0) {
{{ installedAvds().length }} local virtual device{{ installedAvds().length > 1 ? 's' : '' }} available
} @else {
Launch a local Android Virtual Device
}
</span>
</div>
</div>
<div class="method-card-right">
@@ -981,7 +997,6 @@
<div class="method-info">
<div class="method-title-row">
<span class="method-title">USB Cable</span>
<span class="method-badge-pill neutral">4 STEPS</span>
</div>
<span class="method-desc">Connect physical phone via USB</span>
</div>
@@ -1004,7 +1019,6 @@
<div class="method-info">
<div class="method-title-row">
<span class="method-title">Wireless ADB</span>
<span class="method-badge-pill info">WI-FI</span>
</div>
<span class="method-desc">Connect over local Wi-Fi</span>
</div>
@@ -1013,8 +1027,52 @@
<span class="radio-indicator" [class.checked]="activeAdbGuideTab() === 'wifi'"></span>
</div>
</button>
<button
type="button"
class="method-nav-card"
[class.active]="activeAdbGuideTab() === 'remote'"
(click)="setAdbGuideTab('remote')"
>
<div class="method-card-left">
<div class="method-icon-box">
<span class="material-symbols-outlined">devices</span>
</div>
<div class="method-info">
<div class="method-title-row">
<span class="method-title">Remote Computer</span>
</div>
<span class="method-desc">Phone connected to another computer</span>
</div>
</div>
<div class="method-card-right">
<span class="radio-indicator" [class.checked]="activeAdbGuideTab() === 'remote'"></span>
</div>
</button>
</div>
@if (activeAdbGuideTab() !== 'remote' && isRemoteAdbServer()) {
<div class="active-remote-notice">
<div class="active-remote-copy">
<span class="material-symbols-outlined">hub</span>
<span>
Artemis is currently using
<strong>{{ adbServerStatus()?.endpoint?.host }}:{{ adbServerStatus()?.endpoint?.port }}</strong>.
Switch to local ADB before using a local emulator, USB cable, or Wireless ADB.
</span>
</div>
<button
type="button"
class="btn-use-local-adb"
[disabled]="isSwitchingToLocalAdb()"
(click)="switchToLocalAdbServer()"
>
<span class="material-symbols-outlined" [class.spinning]="isSwitchingToLocalAdb()">sync</span>
<span>{{ isSwitchingToLocalAdb() ? 'Switching...' : 'Use Local ADB' }}</span>
</button>
</div>
}
<!-- Method 1: Emulator -->
@if (activeAdbGuideTab() === 'emulator') {
<div class="method-details-panel">
@@ -1049,7 +1107,7 @@
type="button"
class="btn-avd-launch"
[class.launching]="launchingAvd() === avd || (isEmulatorLaunching() && emulatorLaunchState()?.avd_name === avd)"
[disabled]="isEmulatorLaunching()"
[disabled]="isEmulatorLaunching() || isRemoteAdbServer()"
(click)="launchAvdEmulator(avd)"
[title]="isEmulatorLaunching() ? 'An emulator is currently launching...' : 'Launch ' + avd"
>
@@ -1319,7 +1377,7 @@
</div>
<div class="wifi-btn-col">
<button type="button" class="btn-wifi-connect" [disabled]="isConnectingWifi() || !wifiHost().trim()"
<button type="button" class="btn-wifi-connect" [disabled]="isConnectingWifi() || isRemoteAdbServer() || !wifiHost().trim()"
(click)="connectWifiDevice()">
@if (isConnectingWifi()) {
<span class="material-symbols-outlined spinning">sync</span>
@@ -1354,6 +1412,132 @@
</div>
}
<!-- Method 4: ADB server on another computer -->
@if (activeAdbGuideTab() === 'remote') {
<div class="method-details-panel remote-adb-panel">
<div class="remote-guide-intro">
<span class="material-symbols-outlined intro-icon">hub</span>
<span>Enter the host and port of an ADB server endpoint that this computer can access, so Artemis can discover and control a phone physically connected to another computer.</span>
</div>
<div class="remote-adb-interactive-box">
<div class="remote-adb-form-row">
<div class="remote-adb-input-col host-col">
<label class="remote-adb-label" for="remote-adb-host">ADB Server Host</label>
<div class="remote-adb-input-wrap">
<span class="material-symbols-outlined wrap-icon">dns</span>
<input
id="remote-adb-host"
type="text"
class="remote-adb-input"
[ngModel]="remoteAdbHost()"
(ngModelChange)="updateRemoteAdbHost($event)"
placeholder="127.0.0.1"
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
<div class="remote-adb-input-col port-col">
<label class="remote-adb-label" for="remote-adb-port">ADB Server Port</label>
<div class="remote-adb-input-wrap">
<span class="material-symbols-outlined wrap-icon">tag</span>
<input
id="remote-adb-port"
type="text"
inputmode="numeric"
class="remote-adb-input"
[ngModel]="remoteAdbPort()"
(ngModelChange)="updateRemoteAdbPort($event)"
placeholder="5038"
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
<div class="remote-adb-btn-col">
<button
type="button"
class="btn-remote-adb-connect"
[disabled]="isConnectingRemoteAdb() || isActivatingRemoteAdb() || !remoteAdbHost().trim()"
(click)="connectRemoteAdbServer()"
>
@if (isConnectingRemoteAdb()) {
<span class="material-symbols-outlined spinning">sync</span>
<span>Checking...</span>
} @else {
<span class="material-symbols-outlined">bolt</span>
<span>Test Connection</span>
}
</button>
</div>
</div>
<label class="remember-remote-adb">
<input
type="checkbox"
[ngModel]="rememberRemoteAdb()"
(ngModelChange)="rememberRemoteAdb.set($event)"
/>
<span>Remember this endpoint for future Artemis launches</span>
</label>
@if (remoteAdbMessage()) {
<div class="remote-adb-status-alert success">
<span class="material-symbols-outlined">check_circle</span>
<span>{{ remoteAdbMessage() }}</span>
</div>
}
@if (remoteAdbError()) {
<div class="remote-adb-status-alert error" role="alert">
<span class="material-symbols-outlined">error</span>
<span>{{ remoteAdbError() }}</span>
</div>
}
@if (remoteAdbDevices().length > 0) {
<div class="remote-device-results">
@for (device of remoteAdbDevices(); track device.serial) {
<div class="remote-device-row">
<span class="material-symbols-outlined">phone_android</span>
<div class="remote-device-copy">
<strong>{{ device.model || device.serial }}</strong>
<span>{{ device.serial }}</span>
</div>
<span class="remote-device-state" [class.ready]="device.state === 'device'">
{{ device.state === 'device' ? 'Ready' : device.state }}
</span>
</div>
}
</div>
}
@if (remoteAdbProbeResult()?.success && !isProbedRemoteAdbActive()) {
<button
type="button"
class="btn-remote-adb-connect btn-use-tested-endpoint"
[disabled]="isActivatingRemoteAdb()"
(click)="activateRemoteAdbServer()"
>
@if (isActivatingRemoteAdb()) {
<span class="material-symbols-outlined spinning">sync</span>
<span>Connecting...</span>
} @else {
<span class="material-symbols-outlined">check</span>
<span>{{ remoteAdbHasReadyDevice() ? 'Use Connection' : 'Use Anyway' }}</span>
}
</button>
}
</div>
<p class="remote-adb-explanation">
Artemis sends all ADB commands to this server instead of the default local server. Any phone visible to that server becomes available to Artemis.
</p>
</div>
}
</div>
}
@@ -1416,7 +1600,7 @@
</div>
<button type="button" class="btn-retry-auth" (click)="restartAdbServer()">
<span class="material-symbols-outlined">refresh</span>
<span>Restart ADB & Check</span>
<span>{{ isRemoteAdbServer() ? 'Refresh Devices' : 'Restart ADB & Check' }}</span>
</button>
</div>
</div>
@@ -110,12 +110,16 @@ $text-light: #94a3b8;
@mixin glass-primary-btn {
background: linear-gradient(135deg, rgba(26, 115, 232, 0.92) 0%, rgba(37, 99, 235, 0.85) 100%);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
backdrop-filter: blur(10px) saturate(180%);
-webkit-backdrop-filter: blur(10px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.28);
box-shadow: 0 4px 14px rgba(26, 115, 232, 0.32), inset 0 1px 1px rgba(255, 255, 255, 0.45);
color: #ffffff !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
span, .material-symbols-outlined, .run-icon, .action-icon {
color: #ffffff !important;
@@ -146,12 +150,17 @@ $text-light: #94a3b8;
@mixin glass-secondary-btn {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%);
backdrop-filter: blur(10px) saturate(180%);
-webkit-backdrop-filter: blur(10px) saturate(180%);
border: 1px solid rgba(226, 232, 240, 0.88);
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.05), inset 0 1px 1px rgba(255, 255, 255, 0.95);
color: $text-secondary;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.92);
@@ -213,10 +222,13 @@ $text-light: #94a3b8;
opacity: 0.9;
transition: opacity 0.8s ease;
/* The glow orbs are pre-softened radial gradients. A `filter: blur(90px+)`
on top of them looked nearly identical but forced the GPU to re-blur
half-megapixel layers on every frame of the float animations — the
gradients below bake the same softness in for free. */
.glow {
position: absolute;
border-radius: 50%;
filter: blur(90px);
transform: translate3d(0, 0, 0);
will-change: transform, opacity;
pointer-events: none;
@@ -227,7 +239,7 @@ $text-light: #94a3b8;
left: 10%;
width: 520px;
height: 520px;
background: radial-gradient(circle, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.04) 55%, transparent 75%);
background: radial-gradient(circle, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.04) 45%, transparent 82%);
animation: floatGlow1 22s ease-in-out infinite alternate;
}
@@ -236,7 +248,7 @@ $text-light: #94a3b8;
right: 12%;
width: 560px;
height: 560px;
background: radial-gradient(circle, rgba(147, 51, 234, 0.08) 0%, rgba(219, 39, 119, 0.03) 55%, transparent 75%);
background: radial-gradient(circle, rgba(147, 51, 234, 0.08) 0%, rgba(219, 39, 119, 0.03) 45%, transparent 82%);
animation: floatGlow2 26s ease-in-out infinite alternate;
}
@@ -245,7 +257,7 @@ $text-light: #94a3b8;
left: 30%;
width: 460px;
height: 460px;
background: radial-gradient(circle, rgba(6, 182, 212, 0.08) 0%, rgba(59, 130, 246, 0.03) 55%, transparent 75%);
background: radial-gradient(circle, rgba(6, 182, 212, 0.08) 0%, rgba(59, 130, 246, 0.03) 45%, transparent 82%);
animation: floatGlow3 20s ease-in-out infinite alternate;
}
@@ -256,9 +268,8 @@ $text-light: #94a3b8;
margin: 0 auto;
width: 720px;
height: 520px;
background: radial-gradient(circle, rgba(245, 158, 11, 0.09) 0%, rgba(217, 119, 6, 0.035) 45%, transparent 75%);
background: radial-gradient(circle, rgba(245, 158, 11, 0.09) 0%, rgba(217, 119, 6, 0.035) 38%, transparent 82%);
opacity: 0;
filter: blur(100px);
transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1);
animation: readyPulseGlow 9s ease-in-out infinite alternate;
pointer-events: none;
@@ -271,9 +282,8 @@ $text-light: #94a3b8;
margin: 0 auto;
width: 700px;
height: 520px;
background: radial-gradient(circle, rgba(16, 185, 129, 0.14) 0%, rgba(6, 182, 212, 0.08) 45%, transparent 75%);
background: radial-gradient(circle, rgba(16, 185, 129, 0.14) 0%, rgba(6, 182, 212, 0.08) 38%, transparent 82%);
opacity: 0;
filter: blur(100px);
transition: opacity 1s cubic-bezier(0.4, 0, 0.2, 1);
animation: readyPulseGlow 7s ease-in-out infinite alternate;
pointer-events: none;
@@ -286,9 +296,8 @@ $text-light: #94a3b8;
margin: 0 auto;
width: 680px;
height: 520px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.18) 0%, rgba(147, 51, 234, 0.08) 45%, transparent 75%);
background: radial-gradient(circle, rgba(99, 102, 241, 0.18) 0%, rgba(147, 51, 234, 0.08) 38%, transparent 82%);
opacity: 0;
filter: blur(95px);
transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
@@ -383,8 +392,8 @@ $text-light: #94a3b8;
max-width: 520px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
padding: 3px;
border-radius: 9999px;
border: 1px solid rgba(226, 232, 240, 0.85);
@@ -392,7 +401,9 @@ $text-light: #94a3b8;
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.9);
user-select: none;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background: rgba(255, 255, 255, 0.85);
@@ -1717,6 +1728,12 @@ $text-light: #94a3b8;
.meta-dot {
color: $text-light;
}
.remote-endpoint-label {
color: $primary;
font-family: $font-mono;
font-size: 11px;
}
}
}
@@ -1739,6 +1756,7 @@ $text-light: #94a3b8;
animation: statusDotPulse 2s infinite cubic-bezier(0.4, 0, 0.6, 1);
}
}
}
.multi-device-switcher {
@@ -2365,10 +2383,14 @@ $text-light: #94a3b8;
.connection-methods-nav {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
@media (max-width: 860px) {
@media (max-width: 1100px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 620px) {
grid-template-columns: 1fr;
}
@@ -2421,37 +2443,12 @@ $text-light: #94a3b8;
.method-title-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
.method-title {
font-size: 13px;
font-weight: 600;
color: $text-main;
}
.method-badge-pill {
font-size: 9.5px;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
text-transform: uppercase;
&.detected, &.info {
background: $primary-light;
color: $primary;
}
&.recommended {
background: $emerald-light;
color: $emerald;
}
&.neutral {
background: #f1f5f9;
color: $text-muted;
}
}
}
.method-desc {
@@ -2511,6 +2508,68 @@ $text-light: #94a3b8;
}
}
.active-remote-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #fffbeb;
border: 1px solid $amber-border;
border-radius: 8px;
.active-remote-copy {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: #78350f;
font-size: 11.5px;
line-height: 1.45;
.material-symbols-outlined {
color: $amber;
font-size: 17px;
flex-shrink: 0;
}
strong {
font-family: $font-mono;
font-weight: 600;
}
}
.btn-use-local-adb {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 32px;
padding: 0 12px;
@include glass-secondary-btn;
border-radius: 6px;
font-family: inherit;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
.material-symbols-outlined {
font-size: 15px;
}
}
@media (max-width: 700px) {
align-items: stretch;
flex-direction: column;
.btn-use-local-adb {
width: 100%;
}
}
}
.method-details-panel {
background: #ffffff;
border: 1px solid $card-border;
@@ -2852,7 +2911,8 @@ $text-light: #94a3b8;
}
.usb-guide-intro,
.wifi-guide-intro {
.wifi-guide-intro,
.remote-guide-intro {
display: flex;
align-items: center;
gap: 8px;
@@ -3086,6 +3146,258 @@ $text-light: #94a3b8;
margin-top: 1px;
}
}
&.remote-adb-panel {
.remote-adb-interactive-box {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
background: #f8fafc;
border: 1px solid $card-border;
border-radius: 8px;
.remote-adb-form-row {
display: flex;
align-items: flex-end;
gap: 8px;
flex-wrap: wrap;
.remote-adb-input-col {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
&.host-col {
flex: 1 1 220px;
}
&.port-col {
flex: 0 1 120px;
}
.remote-adb-label {
color: $text-muted;
font-size: 11px;
font-weight: 600;
}
.remote-adb-input-wrap {
display: flex;
align-items: center;
height: 36px;
padding: 0 9px;
background: #ffffff;
border: 1px solid $card-border;
border-radius: 6px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
&:focus-within {
border-color: $primary;
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.1);
}
.wrap-icon {
margin-right: 6px;
color: $text-light;
font-size: 15px;
flex-shrink: 0;
}
.remote-adb-input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: $text-main;
font-family: $font-mono;
font-size: 12px;
&::placeholder {
color: $text-light;
}
}
}
}
.remote-adb-btn-col {
flex-shrink: 0;
.btn-remote-adb-connect {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 36px;
padding: 0 16px;
@include glass-primary-btn;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
.material-symbols-outlined {
font-size: 16px;
}
}
}
@media (max-width: 640px) {
align-items: stretch;
flex-direction: column;
.remote-adb-input-col,
.remote-adb-input-col.host-col,
.remote-adb-input-col.port-col,
.remote-adb-btn-col {
flex: 1 1 auto;
width: 100%;
}
.remote-adb-btn-col .btn-remote-adb-connect {
width: 100%;
}
}
}
.remember-remote-adb {
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: 7px;
color: $text-secondary;
font-size: 11.5px;
cursor: pointer;
input {
width: 15px;
height: 15px;
margin: 0;
accent-color: $primary;
}
}
.btn-use-tested-endpoint {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: flex-end;
gap: 5px;
min-width: 150px;
height: 36px;
padding: 0 16px;
@include glass-primary-btn;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
.material-symbols-outlined {
font-size: 16px;
}
}
.remote-adb-status-alert {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 7px 10px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 600;
line-height: 1.4;
.material-symbols-outlined {
font-size: 15px;
flex-shrink: 0;
}
&.success {
color: $emerald-dark;
background: $emerald-light;
border: 1px solid $emerald-border;
}
&.error {
color: $rose-dark;
background: $rose-light;
border: 1px solid $rose-border;
}
}
.remote-device-results {
display: flex;
flex-direction: column;
overflow: hidden;
background: #ffffff;
border: 1px solid $card-border;
border-radius: 7px;
.remote-device-row {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
padding: 8px 10px;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: 0;
}
> .material-symbols-outlined {
color: $primary;
font-size: 17px;
}
.remote-device-copy {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
strong {
color: $text-main;
font-size: 12px;
font-weight: 600;
}
span {
overflow: hidden;
color: $text-muted;
font-family: $font-mono;
font-size: 10.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.remote-device-state {
color: $amber-dark;
font-size: 10.5px;
font-weight: 600;
text-transform: capitalize;
&.ready {
color: $emerald-dark;
}
}
}
}
}
.remote-adb-explanation {
margin: 0;
color: $text-muted;
font-size: 11.5px;
line-height: 1.5;
}
}
}
}
@@ -3496,13 +3808,15 @@ $text-light: #94a3b8;
/* Unified Sculptured Composer (Linear / Claude / ChatGPT Grade) */
.composer-card {
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(28px) saturate(190%);
-webkit-backdrop-filter: blur(28px) saturate(190%);
backdrop-filter: blur(16px) saturate(190%);
-webkit-backdrop-filter: blur(16px) saturate(190%);
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 20px 50px -12px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 18px 20px 14px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
background 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&.is-pro-active {
@@ -4125,12 +4439,15 @@ $text-light: #94a3b8;
padding: 14px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(10px) saturate(180%);
-webkit-backdrop-filter: blur(10px) saturate(180%);
border: 1px solid rgba(226, 232, 240, 0.85);
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.95);
cursor: pointer;
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
transition: background 0.22s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.22s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.22s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.22s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
position: relative;
overflow: hidden;
@@ -4367,18 +4684,17 @@ $text-light: #94a3b8;
}
}
/* Transform-only: animating opacity here overrode the .glow-pending/.glow-ready
fade transitions and kept both state glows permanently visible. */
@keyframes readyPulseGlow {
0% {
transform: scale(0.97);
opacity: 0.7;
}
50% {
transform: scale(1.06);
opacity: 1;
}
100% {
transform: scale(0.98);
opacity: 0.75;
}
}
@@ -4411,3 +4727,13 @@ $text-light: #94a3b8;
opacity: 0.8;
}
}
/* Respect the user's motion preference: freeze the purely decorative ambient
animations (aurora drift, pulses, sparkles). Functional spinners keep going. */
@media (prefers-reduced-motion: reduce) {
.home-container .aurora-background .glow,
.sparkle-icon,
.status-dot {
animation: none !important;
}
}
@@ -14,13 +14,18 @@
* limitations under the License.
*/
import { Component, signal, computed, effect, inject, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, signal, computed, effect, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
import { DeviceInfo, ProbeResult } from '../../core/models/system.model';
import {
AdbServerConnectionResult,
AdbServerDevice,
DeviceInfo,
ProbeResult
} from '../../core/models/system.model';
import {
AppReference,
SmartSuggestion,
@@ -30,12 +35,15 @@ import { TaskRecommendationService } from '../../core/services/task-recommendati
export type { AppReference, SmartSuggestion, SuggestionCategory };
type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [FormsModule],
templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './home.component.scss'
})
export class HomeComponent implements OnInit, OnDestroy {
@@ -47,8 +55,8 @@ export class HomeComponent implements OnInit, OnDestroy {
// High-level navigation mode: 'diagnostics' (System Setup Guide) vs 'launcher' (Task Execution)
public activeTab = signal<'diagnostics' | 'launcher'>('launcher');
// Interactive guide sub-tab inside ADB section: 'emulator' | 'usb' | 'wifi'
public activeAdbGuideTab = signal<'emulator' | 'usb' | 'wifi'>('emulator');
// Interactive guide sub-tab inside the ADB section
public activeAdbGuideTab = signal<AdbGuideTab>('emulator');
public emulatorSetupMode = signal<'studio' | 'cli'>('studio');
// Interactive guide tab for LLM / OCR credentials: 'gemini' | 'ocr'
@@ -90,6 +98,29 @@ export class HomeComponent implements OnInit, OnDestroy {
public wifiConnectMessage = signal<string | null>(null);
public wifiConnectError = signal<string | null>(null);
public adbRestartFeedback = signal<string | null>(null);
public showConnectionMethods = signal<boolean>(false);
// ADB server endpoint connection state
public remoteAdbHost = signal<string>('127.0.0.1');
public remoteAdbPort = signal<string>('5038');
public rememberRemoteAdb = signal<boolean>(true);
public isConnectingRemoteAdb = signal<boolean>(false);
public isActivatingRemoteAdb = signal<boolean>(false);
public isSwitchingToLocalAdb = signal<boolean>(false);
public remoteAdbMessage = signal<string | null>(null);
public remoteAdbError = signal<string | null>(null);
public remoteAdbDevices = signal<AdbServerDevice[]>([]);
public remoteAdbProbeResult = signal<AdbServerConnectionResult | null>(null);
public adbServerStatus = computed(() => this.systemService.adbServerStatus());
public isRemoteAdbServer = computed(() => this.systemService.isRemoteAdbServer());
public remoteAdbHasReadyDevice = computed(() =>
this.remoteAdbDevices().some(device => device.state === 'device')
);
public isProbedRemoteAdbActive = computed(() => {
const tested = this.remoteAdbProbeResult()?.endpoint;
const active = this.adbServerStatus()?.endpoint;
return !!tested && !!active && tested.identity === active.identity;
});
public wifiCommand = computed(() => {
const h = this.wifiHost().trim() || '<phone-ip>';
@@ -334,6 +365,16 @@ export class HomeComponent implements OnInit, OnDestroy {
// Initial fetch of system readiness & model configuration
this.systemService.fetchReadiness().subscribe();
this.systemService.fetchModelConfigEnv().subscribe();
this.systemService.fetchAdbServerStatus().subscribe({
next: status => {
if (status.endpoint.mode === 'remote') {
this.remoteAdbHost.set(status.endpoint.host);
this.remoteAdbPort.set(String(status.endpoint.port));
this.activeAdbGuideTab.set('remote');
}
},
error: () => {}
});
window.addEventListener('focus', this.focusListener);
}
@@ -354,8 +395,10 @@ export class HomeComponent implements OnInit, OnDestroy {
this.selectedOs.set(os);
}
public setAdbGuideTab(tab: 'emulator' | 'usb' | 'wifi'): void {
public setAdbGuideTab(tab: AdbGuideTab): void {
this.activeAdbGuideTab.set(tab);
this.remoteAdbError.set(null);
this.remoteAdbMessage.set(null);
}
public setEmulatorSetupMode(mode: 'studio' | 'cli'): void {
@@ -609,7 +652,9 @@ export class HomeComponent implements OnInit, OnDestroy {
this.adbRestartFeedback.set(null);
this.systemService.restartAdb().subscribe({
next: (res) => {
this.adbRestartFeedback.set('ADB Refreshed ✓');
this.adbRestartFeedback.set(
res?.restart_result?.skipped ? 'Devices Refreshed ✓' : 'ADB Refreshed ✓'
);
setTimeout(() => this.adbRestartFeedback.set(null), 2500);
},
error: () => {
@@ -620,6 +665,10 @@ export class HomeComponent implements OnInit, OnDestroy {
}
public connectWifiDevice(): void {
if (this.isRemoteAdbServer()) {
this.wifiConnectError.set('Switch to local ADB before connecting a Wireless ADB device.');
return;
}
const host = this.wifiHost().trim();
const portStr = this.wifiPort().trim() || '5555';
const port = parseInt(portStr, 10) || 5555;
@@ -651,7 +700,123 @@ export class HomeComponent implements OnInit, OnDestroy {
});
}
public connectRemoteAdbServer(): void {
const host = this.remoteAdbHost().trim();
const port = Number(this.remoteAdbPort().trim());
if (!host) {
this.remoteAdbError.set('Enter the host name or IP address of the ADB server.');
return;
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
this.remoteAdbError.set('Enter a port between 1 and 65535.');
return;
}
this.isConnectingRemoteAdb.set(true);
this.remoteAdbError.set(null);
this.remoteAdbMessage.set(null);
this.remoteAdbDevices.set([]);
this.remoteAdbProbeResult.set(null);
this.systemService.probeAdbServer(host, port).subscribe({
next: response => {
this.isConnectingRemoteAdb.set(false);
const result = response.connection_result;
if (result.success) {
this.remoteAdbProbeResult.set(result);
this.remoteAdbDevices.set(result.devices || []);
this.remoteAdbMessage.set(result.message);
} else {
this.remoteAdbError.set(result.message);
}
},
error: error => {
this.isConnectingRemoteAdb.set(false);
this.remoteAdbError.set(
error?.error?.detail || 'Unable to test the ADB server endpoint.'
);
}
});
}
public activateRemoteAdbServer(): void {
const tested = this.remoteAdbProbeResult();
if (!tested?.success) {
this.remoteAdbError.set('Test the endpoint before using it.');
return;
}
this.isActivatingRemoteAdb.set(true);
this.remoteAdbError.set(null);
this.systemService.connectAdbServer(
tested.endpoint.host,
tested.endpoint.port,
this.rememberRemoteAdb()
).subscribe({
next: response => {
this.isActivatingRemoteAdb.set(false);
const result = response.connection_result;
if (result.success) {
this.remoteAdbProbeResult.set(result);
this.remoteAdbDevices.set(result.devices || []);
this.remoteAdbMessage.set(result.message);
} else {
this.remoteAdbError.set(result.message);
}
},
error: error => {
this.isActivatingRemoteAdb.set(false);
this.remoteAdbError.set(
error?.error?.detail || 'Unable to use the ADB server endpoint.'
);
}
});
}
public updateRemoteAdbHost(value: string): void {
this.remoteAdbHost.set(value);
this.clearRemoteAdbProbe();
}
public updateRemoteAdbPort(value: string): void {
this.remoteAdbPort.set(value);
this.clearRemoteAdbProbe();
}
private clearRemoteAdbProbe(): void {
this.remoteAdbProbeResult.set(null);
this.remoteAdbDevices.set([]);
this.remoteAdbMessage.set(null);
this.remoteAdbError.set(null);
}
public switchToLocalAdbServer(): void {
this.isSwitchingToLocalAdb.set(true);
this.remoteAdbError.set(null);
this.systemService.useLocalAdbServer(true).subscribe({
next: response => {
this.isSwitchingToLocalAdb.set(false);
this.remoteAdbDevices.set([]);
this.remoteAdbMessage.set(response.connection_result.message);
},
error: error => {
this.isSwitchingToLocalAdb.set(false);
this.remoteAdbError.set(
error?.error?.detail || 'Unable to switch back to the local ADB server.'
);
}
});
}
public toggleConnectionMethods(): void {
this.showConnectionMethods.update(value => !value);
}
public launchAvdEmulator(avdName: string): void {
if (this.isRemoteAdbServer()) {
return;
}
this.systemService.launchEmulator(avdName).subscribe();
}
@@ -14,16 +14,17 @@
* limitations under the License.
*/
import { Component, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, HostListener, ChangeDetectionStrategy } from '@angular/core';
import { LegacyAgentStreamComponent } from '../../components/legacy-agent-stream/legacy-agent-stream.component';
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
@Component({
selector: 'app-legacy-workspace',
standalone: true,
imports: [CommonModule, LegacyAgentStreamComponent, ChatInterfaceComponent],
imports: [LegacyAgentStreamComponent, ChatInterfaceComponent],
templateUrl: './legacy-workspace.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './legacy-workspace.component.scss'
})
export class LegacyWorkspaceComponent {
@@ -1,20 +1,20 @@
<!--
Copyright 2026 Google LLC
Copyright 2026 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="workspace-container" [class.is-resizing]="isDragging">
<div class="workspace-container" [class.is-resizing]="isDragging()">
<!-- Left Main Panel: Raw Agent Log Stream -->
<main class="left-panel glass-panel">
<app-agent-stream></app-agent-stream>
@@ -23,13 +23,15 @@
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
[class.is-focused]="isInputFocused()" [class.has-text]="!!taskInput.trim()" [class.is-running]="isTaskRunning()">
<!-- Floating Error / Notification Toast -->
<div class="floating-dock-toast error" *ngIf="errorMessage">
<span class="material-symbols-outlined toast-icon">error</span>
<span class="toast-text">{{ errorMessage }}</span>
<button type="button" class="btn-toast-close" (click)="errorMessage = null" aria-label="Close error message">
<span class="material-symbols-outlined">close</span>
</button>
</div>
@if (errorMessage()) {
<div class="floating-dock-toast error">
<span class="material-symbols-outlined toast-icon">error</span>
<span class="toast-text">{{ errorMessage() }}</span>
<button type="button" class="btn-toast-close" (click)="errorMessage.set(null)" aria-label="Close error message">
<span class="material-symbols-outlined">close</span>
</button>
</div>
}
<!-- Dock Row (Contains the Main Card and the Separate Stop Button when running) -->
<div class="floating-dock-row">
@@ -52,9 +54,9 @@
</linearGradient>
</defs>
<path d="M 0,25 Q 300,55 600,20 T 1200,25 Q 1500,55 1800,20 T 2400,25 L 2400,80 L 0,80 Z"
fill="url(#waveGradBack)"></path>
fill="url(#waveGradBack)"></path>
</svg>
<!-- Mid Fluid Wave -->
<svg class="liquid-wave wave-mid" viewBox="0 0 2400 80" preserveAspectRatio="none">
<defs>
@@ -66,9 +68,9 @@
</linearGradient>
</defs>
<path d="M 0,35 Q 300,5 600,45 T 1200,35 Q 1500,5 1800,45 T 2400,35 L 2400,80 L 0,80 Z"
fill="url(#waveGradMid)"></path>
fill="url(#waveGradMid)"></path>
</svg>
<!-- Front Fluid Wave -->
<svg class="liquid-wave wave-front" viewBox="0 0 2400 80" preserveAspectRatio="none">
<defs>
@@ -80,16 +82,16 @@
</linearGradient>
</defs>
<path d="M 0,18 Q 300,50 600,15 T 1200,18 Q 1500,50 1800,15 T 2400,18 L 2400,80 L 0,80 Z"
fill="url(#waveGradFront)"></path>
fill="url(#waveGradFront)"></path>
</svg>
</div>
</div>
<!-- Dormant / Capsule Peek View (Visible when mouse is away and input idle) -->
<div class="capsule-peek-view">
<span class="capsule-placeholder">Start a new task...</span>
</div>
<!-- Expanded Full View Content (Visible on hover, focus, or text) -->
<div class="expanded-card-content">
<div class="card-main-input-row">
@@ -101,7 +103,7 @@
(focus)="onInputFocus()"
(blur)="onInputBlur()"
rows="1"
[disabled]="isSubmitting"
[disabled]="isSubmitting()"
[placeholder]="selectedProfile() === 'pro'
? 'Pro: Multi-agent cognitive graph architecture for complex multi-step tasks'
: 'Flash: Reactive fast loop for standard app workflows within 25 steps'"
@@ -111,30 +113,38 @@
</div>
<!-- Clear Button (visible when text is entered) -->
<button *ngIf="taskInput.trim() && !isSubmitting" type="button" class="dock-circle-btn btn-clear"
(click)="clearInput($event)" title="Clear input text" aria-label="Clear input text">
<span class="material-symbols-outlined">close</span>
</button>
@if (taskInput.trim() && !isSubmitting()) {
<button type="button" class="dock-circle-btn btn-clear"
(click)="clearInput($event)" title="Clear input text" aria-label="Clear input text">
<span class="material-symbols-outlined">close</span>
</button>
}
<!-- Stop Button (Visible when currently viewed task is active) -->
<button *ngIf="isTaskRunning()" type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting"
(click)="stopTask($event)"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
@if (isTaskRunning()) {
<button type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting()"
(click)="stopTask($event)"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
}
<!-- Send Button -->
<button type="button" class="dock-circle-btn btn-send"
[class.has-content]="!!taskInput.trim()"
[disabled]="!taskInput.trim() || isSubmitting"
[disabled]="!taskInput.trim() || isSubmitting()"
(click)="submitTask()"
[title]="isSubmitting ? 'Starting task...' : 'Start task (Enter)'"
[title]="isSubmitting() ? 'Starting task...' : 'Start task (Enter)'"
aria-label="Start task"
>
<span class="material-symbols-outlined spinning-icon" *ngIf="isSubmitting">sync</span>
<span class="material-symbols-outlined" *ngIf="!isSubmitting">arrow_upward</span>
>
@if (isSubmitting()) {
<span class="material-symbols-outlined spinning-icon">sync</span>
}
@if (!isSubmitting()) {
<span class="material-symbols-outlined">arrow_upward</span>
}
</button>
</div>
@@ -162,28 +172,30 @@
</div>
<!-- Separate Standalone Stop Button Beside the Capsule (Visible when currently viewed task is active) -->
<button *ngIf="isTaskRunning()" type="button" class="standalone-stop-btn"
[disabled]="isSubmitting"
(click)="stopTask($event)"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
@if (isTaskRunning()) {
<button type="button" class="standalone-stop-btn"
[disabled]="isSubmitting()"
(click)="stopTask($event)"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
}
</div>
</div>
</main>
<!-- Split Pane Drag Resizer -->
<div class="resizer" (mousedown)="onDragStart($event)">
<div class="resizer-handle"></div>
</div>
<!-- Right Sidebar Panel: Chat Interface Mock -->
<aside class="right-panel" [style.width.px]="rightPanelWidth">
<app-chat-interface></app-chat-interface>
</aside>
<!-- Floating Video Replay Player Modal / Window -->
<app-floating-video-player></app-floating-video-player>
<!-- Split Pane Drag Resizer -->
<div class="resizer" (mousedown)="onDragStart($event)">
<div class="resizer-handle"></div>
</div>
<!-- Right Sidebar Panel: Chat Interface Mock -->
<aside class="right-panel" [style.width.px]="rightPanelWidth()">
<app-chat-interface></app-chat-interface>
</aside>
<!-- Floating Video Replay Player Modal / Window -->
<app-floating-video-player></app-floating-video-player>
</div>
@@ -104,7 +104,7 @@ $rose-dark: #dc2626;
height: 40px;
background: rgba(0, 0, 0, 0.15);
border-radius: 4px;
transition: all 0.2s ease;
transition: background 0.2s ease, height 0.2s ease;
}
}
@@ -308,7 +308,11 @@ $rose-dark: #dc2626;
background: radial-gradient(ellipse at center, rgba(168, 85, 247, 0.45) 0%, rgba(236, 72, 153, 0.25) 50%, transparent 80%);
}
/* Wave Tracks Wrapper with Soft Gaussian Blur */
/* Wave Tracks Wrapper. The soft-liquid look comes from blurring each
wave individually: blurring the WRAPPER forced the GPU to re-run the
gaussian pass every animation frame (child transforms invalidate the
parent's filtered layer), while per-wave blur over transform-only
animations is rasterized once and then just composited. */
.wave-tracks-wrapper {
position: absolute;
bottom: 0;
@@ -316,7 +320,6 @@ $rose-dark: #dc2626;
width: 100%;
height: 22px;
overflow: hidden;
filter: blur(7px); /* Soften sharp vector wave edges into silky liquid light */
mask-image: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 60%, transparent 100%);
-webkit-mask-image: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 60%, transparent 100%);
@@ -327,7 +330,7 @@ $rose-dark: #dc2626;
width: 200%;
height: 100%;
will-change: transform;
filter: blur(3px); /* Secondary soft layer blur */
filter: blur(8px); /* Silky liquid softness, applied per cached wave layer */
&.wave-back {
opacity: 0.45;
@@ -562,7 +565,11 @@ $rose-dark: #dc2626;
cursor: pointer;
outline: none;
flex-shrink: 0;
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
transition: background 0.18s cubic-bezier(0.16, 1, 0.3, 1),
color 0.18s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.18s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.18s cubic-bezier(0.16, 1, 0.3, 1),
border-color 0.18s cubic-bezier(0.16, 1, 0.3, 1);
.material-symbols-outlined {
font-size: 18px;
@@ -695,7 +702,9 @@ $rose-dark: #dc2626;
font-size: 11px;
font-weight: 550;
cursor: pointer;
transition: all 0.16s cubic-bezier(0.16, 1, 0.3, 1);
transition: color 0.16s cubic-bezier(0.16, 1, 0.3, 1),
background 0.16s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.16s cubic-bezier(0.16, 1, 0.3, 1);
outline: none;
user-select: none;
box-sizing: border-box;
@@ -863,3 +872,14 @@ $rose-dark: #dc2626;
transform: scaleX(1.08);
}
}
/* Respect the user's motion preference: freeze the decorative liquid waves
and pulses while keeping the frosted dock visuals intact. */
@media (prefers-reduced-motion: reduce) {
.liquid-wave,
.wave-glow-ambient,
.pulse-indicator-dot,
.running-pulse-dot {
animation: none !important;
}
}
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { Component, HostListener, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, NgZone, DestroyRef, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
@@ -26,26 +26,35 @@ import { AgentService } from '../../services/agent.service';
selector: 'app-workspace',
standalone: true,
imports: [
CommonModule,
FormsModule,
AgentStreamComponent,
ChatInterfaceComponent,
FloatingVideoPlayerComponent
],
],
templateUrl: './workspace.component.html',
styleUrl: './workspace.component.scss'
styleUrl: './workspace.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WorkspaceComponent implements OnInit {
public agentService = inject(AgentService);
private zone = inject(NgZone);
private destroyRef = inject(DestroyRef);
// Default right panel width to 1/3 of the screen (or 450px as fallback)
public rightPanelWidth = typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450;
public isDragging = false;
public rightPanelWidth = signal<number>(
typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450
);
public isDragging = signal<boolean>(false);
private dragWidthRafId: number | null = null;
private pendingDragWidth = 0;
// Floating Command Bar State
public taskInput: string = '';
public isSubmitting: boolean = false;
public errorMessage: string | null = null;
// Floating Command Bar State. taskInput is backed by a signal so computed
// expressions (isBarExpanded) genuinely track it under OnPush.
private taskInputSignal = signal<string>('');
public get taskInput(): string { return this.taskInputSignal(); }
public set taskInput(value: string) { this.taskInputSignal.set(value); }
public isSubmitting = signal<boolean>(false);
public errorMessage = signal<string | null>(null);
public selectedProfile = signal<'flash' | 'pro'>('flash');
// Expand States (Signals for 0-latency reactivity)
@@ -61,6 +70,16 @@ export class WorkspaceComponent implements OnInit {
this.selectedProfile.set(saved);
}
}
// The global ⌘K/Ctrl+K shortcut is registered outside the Angular zone so
// ordinary typing never schedules an extra change-detection pass.
this.zone.runOutsideAngular(() => {
window.addEventListener('keydown', this.onGlobalKeyDown);
});
this.destroyRef.onDestroy(() => {
window.removeEventListener('keydown', this.onGlobalKeyDown);
this.detachDragListeners();
});
}
/**
@@ -179,13 +198,12 @@ export class WorkspaceComponent implements OnInit {
/**
* Global keyboard shortcut (⌘K or Ctrl+K) to focus input bar from anywhere
*/
@HostListener('window:keydown', ['$event'])
public onGlobalKeyDown(event: KeyboardEvent): void {
private onGlobalKeyDown = (event: KeyboardEvent): void => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
this.focusInput();
}
}
};
/**
* Handle enter key in floating dock textarea
@@ -202,12 +220,12 @@ export class WorkspaceComponent implements OnInit {
*/
public submitTask(): void {
const goal = this.taskInput.trim();
if (!goal || this.isSubmitting) {
if (!goal || this.isSubmitting()) {
return;
}
this.isSubmitting = true;
this.errorMessage = null;
this.isSubmitting.set(true);
this.errorMessage.set(null);
if (this.dockInputRef?.nativeElement) {
this.dockInputRef.nativeElement.blur();
@@ -220,15 +238,15 @@ export class WorkspaceComponent implements OnInit {
if (this.dockInputRef?.nativeElement) {
this.dockInputRef.nativeElement.style.height = 'auto';
}
this.isSubmitting = false;
this.isSubmitting.set(false);
this.agentService.fetchStatus();
},
error: (err) => {
console.error('Failed to submit task:', err);
this.isSubmitting = false;
this.errorMessage = err.error?.detail || 'The runner is busy. Please wait for current task to finish.';
this.isSubmitting.set(false);
this.errorMessage.set(err.error?.detail || 'The runner is busy. Please wait for current task to finish.');
setTimeout(() => {
this.errorMessage = null;
this.errorMessage.set(null);
}, 5000);
}
});
@@ -245,28 +263,31 @@ export class WorkspaceComponent implements OnInit {
return;
}
const targetSessionId = this.agentService.currentSessionId();
this.isSubmitting = true;
this.errorMessage = null;
this.isSubmitting.set(true);
this.errorMessage.set(null);
this.agentService.stopTask(targetSessionId, false);
setTimeout(() => {
this.isSubmitting = false;
this.isSubmitting.set(false);
}, 400);
}
/**
* Handle mouse down on resizer bar to start dragging
* Handle mouse down on resizer bar to start dragging. The move/up listeners
* are attached only for the duration of the drag and run outside the Angular
* zone: idle mouse movement over the workspace never triggers change
* detection, and drag updates are coalesced to one per animation frame.
*/
public onDragStart(event: MouseEvent): void {
this.isDragging = true;
this.isDragging.set(true);
event.preventDefault();
this.zone.runOutsideAngular(() => {
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
});
}
/**
* Listen to global mousemove events to adjust the width dynamically
*/
@HostListener('document:mousemove', ['$event'])
public onMouseMove(event: MouseEvent): void {
if (!this.isDragging) {
private onMouseMove = (event: MouseEvent): void => {
if (!this.isDragging()) {
return;
}
@@ -276,15 +297,27 @@ export class WorkspaceComponent implements OnInit {
// Apply boundary limits to prevent panels from shrinking too much
if (newWidth >= minWidth && newWidth <= maxWidth) {
this.rightPanelWidth = newWidth;
this.pendingDragWidth = newWidth;
if (this.dragWidthRafId === null) {
this.dragWidthRafId = requestAnimationFrame(() => {
this.dragWidthRafId = null;
this.rightPanelWidth.set(this.pendingDragWidth);
});
}
}
};
private onMouseUp = (): void => {
this.isDragging.set(false);
this.detachDragListeners();
};
private detachDragListeners(): void {
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
if (this.dragWidthRafId !== null) {
cancelAnimationFrame(this.dragWidthRafId);
this.dragWidthRafId = null;
}
}
/**
* Listen to global mouseup events to stop resizing
*/
@HostListener('document:mouseup')
public onMouseUp(): void {
this.isDragging = false;
}
}