Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f32b254
fix: refresh statistics and usage data every 30 seconds
MusicMaster4 Sep 24, 2026
c2a5d81
fix: allow earlier quota forecasts from meaningful window usage
MusicMaster4 Sep 24, 2026
4501c5e
feat: update
MusicMaster4 Sep 24, 2026
5564f81
feat: save progress
MusicMaster4 Sep 25, 2026
bb204a0
fix: keep asynchronously launched subagents running until results arrive
MusicMaster4 Sep 25, 2026
7fa2548
feat: add collapsible tab groups with grouping controls and persisted…
MusicMaster4 Sep 27, 2026
b919cd4
feat: recover workspace state and scheduled sends across restarts
MusicMaster4 Sep 27, 2026
6156764
feat: add tab group colors and management controls
MusicMaster4 Sep 27, 2026
ac889f8
Polish mobile workflows and improve desktop synchronization
MusicMaster4 Sep 28, 2026
13db0d1
feat: update
MusicMaster4 Sep 28, 2026
8b47933
feat: update
MusicMaster4 Sep 29, 2026
e3f4fbd
feat: update
MusicMaster4 Sep 29, 2026
42f772c
feat: save progress
MusicMaster4 Sep 30, 2026
8800e05
feat: automatically reconnect public tunnels and refresh forwarding l…
MusicMaster4 Sep 30, 2026
f2df2dd
fix: keep oversized Codex responses from breaking shared sessions
MusicMaster4 Oct 1, 2026
c53d69e
feat: add CLI proxy dashboard with quota caching, management bridge, …
MusicMaster4 Oct 2, 2026
3027bae
feat: update
MusicMaster4 Oct 2, 2026
2715b03
fix: restore proxy management and prioritize sticky account routing
MusicMaster4 Oct 2, 2026
3cefa43
fix: align agent model pickers with live CLI catalogs
MusicMaster4 Oct 2, 2026
48bcd59
fix: hide unsupported effort in agent controls
MusicMaster4 Oct 2, 2026
749f87c
feat: update
MusicMaster4 Oct 2, 2026
9f23af1
fix: preserve terminal control sequences during syntax highlighting
MusicMaster4 Oct 2, 2026
97db341
feat: add syntax highlighting for changed code diffs
MusicMaster4 Oct 2, 2026
edfd708
feat: update
MusicMaster4 Oct 3, 2026
3b2f062
fix: recover completion audio after output stream failures
MusicMaster4 Oct 6, 2026
d5de6e2
feat: update
MusicMaster4 Oct 7, 2026
76a68f4
feat: unify mobile agent experience and accelerate sync
MusicMaster4 Oct 8, 2026
9905cb0
fix: reclaim expired relay deliveries before schema updates
MusicMaster4 Oct 8, 2026
fd5a0e6
fix: execute relay cleanup through direct D1 queries
MusicMaster4 Oct 8, 2026
c3cdc2a
fix: preserve multiline SQL as a single Wrangler argument
MusicMaster4 Oct 8, 2026
dc7a501
fix: minimize state index size for a full relay database
MusicMaster4 Oct 8, 2026
02aef4d
feat: update
MusicMaster4 Oct 8, 2026
4d14e85
feat: add a
MusicMaster4 Oct 8, 2026
3313c1f
feat: render agent markdown assets and safely open local document links
MusicMaster4 Oct 9, 2026
07cac0f
fix: preserve shared codex work across proxy disconnects and reconnec…
MusicMaster4 Oct 9, 2026
5070dcb
feat: synchronize Codex transport preferences and fall back to HTTP f…
MusicMaster4 Oct 9, 2026
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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ jobs:
steps:
- uses: actions/checkout@v5

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest

- run: bun install --frozen-lockfile

- uses: actions/setup-java@v5
with:
distribution: temurin
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/relay.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ jobs:
CLOUDFLARE_D1_DATABASE_ID: ${{ vars.CLOUDFLARE_D1_DATABASE_ID }}
run: node relay/scripts/configure-deploy.mjs

- name: Reclaim expired relay deliveries before migrating
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
run: npx --prefix relay wrangler d1 execute DB --remote --config relay/wrangler.deploy.json --command="$(cat relay/scripts/prune-expired.sql)"

- name: Apply D1 migrations
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,12 @@ jobs:
steps:
- uses: actions/checkout@v5

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest

- run: bun install --frozen-lockfile

- uses: actions/setup-java@v5
with:
distribution: temurin
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ relay/node_modules
relay/.wrangler
relay/wrangler.deploy.json
android/.gradle
android/.kotlin
android/local.properties
android/**/build
android/app/google-services.json
Expand Down
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,8 +461,24 @@ tab over the internet. Share the frontend once: browser calls to localhost APIs,
WebSockets and event streams go through that same HTTPS link. Start the frontend
and backend in panes of the same tab before sharing. Public links are intended for
development and testing, not production. Duckweed shows the link only after an
end-to-end readiness check reaches its local proxy. It stops the tunnel when you
stop sharing, close the owning process, or exit the app.
browser-style HTTPS check reaches its local proxy using normal DNS, without
bypassing provider warning pages. It also checks that the selected local server
responds over HTTP. Responses, uploads, WebSockets, and event streams are forwarded
without buffering the whole body. HTML adaptation preserves the document structure
and handles gzip, deflate, and Brotli responses, local asset URLs, redirects, and
cookies. A server restart within the same pane has a 30-second grace period to
keep the existing link. Ownership is checked again for incoming requests.

Keep Duckweed and the servers running, and the computer awake and online. Stop
sharing revokes access and closes existing connections. Closing the process from
the Ports tool or exiting Duckweed also stops its tunnel. Links are temporary and
depend on the tunnel provider and the visitor's network. Cloudflare Quick Tunnels
[do not support SSE](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/);
Duckweed displays a notice when using that fallback. No tunnel can make every
development app portable automatically: OAuth redirect allowlists, local HTTPS
servers, URLs embedded in worker scripts or stylesheets, and apps with strict
deployment-origin settings can require configuration for the public URL. Only
ports belonging to panes included in the shared tab are authorized.

## Keyboard shortcuts

Expand Down Expand Up @@ -558,6 +574,20 @@ GitHub CI runs the TypeScript check, Bun test suite, and `cargo check` on Linux,
macOS, and Windows. Native release packaging is also performed on the matching
operating system before a release can be published.

Port-sharing regression tests run with `cargo test --manifest-path
src-tauri/Cargo.toml ports::`. Two opt-in tests create temporary public tunnels:
`public_tunnel_is_reachable_end_to_end` checks HTTPS and backend routing;
`public_browser_app_works_end_to_end` uses Node and an isolated headless browser
to check uploads, cookies, redirects, XHR, SSE, WebSockets, assets, and compressed
HTML. Set `DUCKWEED_TEST_BROWSER` to a Chrome or Edge executable, then run:

```bash
cargo test --manifest-path src-tauri/Cargo.toml public_browser_app_works_end_to_end -- --ignored --nocapture
```

These tests serve synthetic fixtures only and stop their servers and tunnels
when finished. They require internet access and an available tunnel provider.

Release builds come from two branches: `main` publishes stable releases and
`testing` publishes beta releases. The full versioning, signing, and updater
setup is documented in [docs/releases.md](docs/releases.md).
Expand Down
11 changes: 11 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ android {
buildConfig = true
}

val mobileAssets = layout.buildDirectory.dir("generated/agentAssets")
sourceSets["main"].assets.srcDir(mobileAssets)
val buildMobileExperience by tasks.registering(Exec::class) {
workingDir(rootProject.projectDir.resolve(".."))
commandLine(if (System.getProperty("os.name").startsWith("Windows")) "bun.exe" else "bun", "run", "build:mobile")
inputs.files(fileTree(rootProject.projectDir.resolve("../src")), rootProject.projectDir.resolve("../vite.mobile.config.ts"), rootProject.projectDir.resolve("../mobile.html"), rootProject.projectDir.resolve("../bun.lock"), rootProject.projectDir.resolve("../package.json"))
outputs.dir(mobileAssets)
}
tasks.named("preBuild").configure { dependsOn(buildMobileExperience) }

val generatedSounds = layout.buildDirectory.dir("generated/res/completionSounds")
sourceSets["main"].res.srcDir(generatedSounds)
val syncCompletionSounds by tasks.registering(Sync::class) {
Expand All @@ -80,6 +90,7 @@ dependencies {
implementation("com.google.firebase:firebase-messaging")
implementation("com.google.android.gms:play-services-code-scanner:16.1.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.biometric:biometric:1.1.0")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.recyclerview:recyclerview:1.4.0")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package dev.slop.duckweed.companion

import android.content.Intent
import android.graphics.Bitmap
import android.view.View
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File

@RunWith(AndroidJUnit4::class)
class MobilePolishTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context get() = instrumentation.targetContext
private val pairId = "00000000-0000-4000-8000-000000000001"
private val terminalId = "polish-terminal"
private var now = 0L
private val commands = listOf(
RemoteSlashCommand("/new", "Start a new conversation"),
RemoteSlashCommand("/model", "Choose a model", listOf(
RemoteCommandOption("provider/fast", "Fast", "Quick everyday work", true),
RemoteCommandOption("provider/deep", "Deep", "Complex coding tasks"),
)),
RemoteSlashCommand("/effort", "Change reasoning effort", listOf(
RemoteCommandOption("low", "Low", "Faster responses"),
RemoteCommandOption("high", "High", "More detailed reasoning", true),
)),
)
private fun snapshot(at: Long = now, closed: Boolean = false) = WorkspaceSnapshot(pairId, at, listOf(
RemoteProject("mobile", "Mobile experience", "H:/duckweed", "testing", "#45cec4", if (closed) emptyList() else listOf(
RemoteTerminal(terminalId, "Codex", "PowerShell", "Codex", "Fast", "idle", mode = "conversation", commands = commands,
conversation = listOf(RemoteConversationMessage("answer", now - 1000, "assistant", "The mobile sync update is ready to review."))),
)),
RemoteProject("api", "API improvements", "H:/api", "main", "#c98bf0", listOf(
RemoteTerminal("api-terminal", "Claude", "PowerShell", "Claude", "Sonnet", "waiting", mode = "conversation"),
)),
RemoteProject("release", "Release checklist", "H:/duckweed", "release", terminals = listOf(
RemoteTerminal("release-terminal", "Terminal", "PowerShell", null, null, "idle"),
)),
))

@Before fun seed() {
if (android.os.Build.VERSION.SDK_INT >= 33) {
instrumentation.uiAutomation.grantRuntimePermission(context.packageName, android.Manifest.permission.POST_NOTIFICATIONS)
}
now = System.currentTimeMillis()
context.deleteDatabase("duckweed-messages.db")
WorkspaceStore(context).remove(pairId)
DraftStore(context).save(pairId, terminalId, ConversationDraft("", null))
SecretStore.save(context, PairCredentials(pairId, "https://127.0.0.1:1", "A".repeat(43), "B".repeat(43), "test-device", "Test device"))
WorkspaceStore(context).put(snapshot())
}

@Test fun searchableListsAndFiltersKeepDesktopTitles() {
ActivityScenario.launch<MainActivity>(Intent(context, MainActivity::class.java)).use { scenario ->
scenario.onActivity { it.findViewById<View>(R.id.nav_projects).performClick() }
await { var ready = false; scenario.onActivity { ready = it.findViewById<RecyclerView>(R.id.project_list).adapter?.itemCount == 3 }; ready }
instrumentation.waitForIdleSync()
screenshot("mobile-tabs-polished.png")
// A query cleared before its background diff finishes must not reappear.
scenario.onActivity {
it.findViewById<EditText>(R.id.project_search).setText("api")
it.findViewById<EditText>(R.id.project_search).text.clear()
}
await { var ready = false; scenario.onActivity { ready = it.findViewById<RecyclerView>(R.id.project_list).adapter?.itemCount == 3 }; ready }
scenario.onActivity { it.findViewById<EditText>(R.id.project_search).setText("mobile codex") }
await { var ready = false; scenario.onActivity { ready = it.findViewById<RecyclerView>(R.id.project_list).adapter?.itemCount == 1 }; ready }
scenario.onActivity { activity ->
val list = activity.findViewById<RecyclerView>(R.id.project_list)
assertEquals("Mobile experience", list.findViewHolderForAdapterPosition(0)!!.itemView.findViewById<TextView>(R.id.project_name).text.toString())
activity.findViewById<EditText>(R.id.project_search).setText("does not exist")
assertEquals(View.VISIBLE, activity.findViewById<View>(R.id.projects_empty).visibility)
activity.findViewById<View>(R.id.projects_empty_action).performClick()
assertEquals("", activity.findViewById<EditText>(R.id.project_search).text.toString())
activity.findViewById<View>(R.id.nav_conversations).performClick()
}
await { var ready = false; scenario.onActivity { ready = it.findViewById<View>(R.id.conversations_page).let { page -> page.isShown && page.alpha == 1f } }; ready }
screenshot("mobile-conversations-polished.png")
scenario.onActivity { it.findViewById<LinearLayout>(R.id.conversation_filters).getChildAt(1).performClick() }
await { var ready = false; scenario.onActivity { ready = it.findViewById<RecyclerView>(R.id.conversations_list).adapter?.itemCount == 1 }; ready }
}
}

@Test fun slashChoicesPersistAndStreamingDoesNotRebuildThePicker() {
val message = CompletionRecord(id = "polish-message", pairId = pairId, sentAt = now, agent = "Codex",
project = "Mobile experience", kind = "completed", response = "Ready", durationMs = null,
projectId = "mobile", terminalId = terminalId)
MessageStore(context).use { it.put(message); it.markNotified(message.id) }
ActivityScenario.launch<MainActivity>(Intent(context, MainActivity::class.java).putExtra("message_id", message.id)).use { scenario ->
await { var ready = false; scenario.onActivity { ready = it.findViewById<EditText>(R.id.conversation_input).let { view -> view.isShown && view.isEnabled } }; ready }
scenario.onActivity { activity ->
assertEquals("Mobile experience", activity.findViewById<TextView>(R.id.conversation_title).text.toString())
activity.findViewById<View>(R.id.conversation_command_button).performClick()
val input = activity.findViewById<EditText>(R.id.conversation_input)
input.setText("/model ")
val rows = activity.findViewById<LinearLayout>(R.id.conversation_commands)
assertEquals(2, rows.childCount)
val first = rows.getChildAt(0)
MainActivity::class.java.getDeclaredMethod("refreshConversation", Boolean::class.javaPrimitiveType).apply { isAccessible = true }.invoke(activity, false)
assertSame(first, rows.getChildAt(0))
rows.getChildAt(1).performClick()
assertEquals("/model provider/deep ", input.text.toString())
assertEquals(View.GONE, activity.findViewById<View>(R.id.conversation_commands_scroll).visibility)
input.setText("/effort ")
}
instrumentation.waitForIdleSync()
screenshot("mobile-command-choices.png")
WorkspaceStore(context).put(snapshot(now + 1000, closed = true))
NotificationTools.announceChanged(context)
await { var closed = false; scenario.onActivity { closed = it.findViewById<TextView>(R.id.conversation_unavailable).text.contains("closed") }; closed }
scenario.onActivity { assertFalse(it.findViewById<View>(R.id.conversation_send).isEnabled) }
}
}

private fun screenshot(name: String) {
instrumentation.uiAutomation.takeScreenshot()?.let { bitmap ->
File(context.getExternalFilesDir(null), name).outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
bitmap.recycle()
}
}
private fun await(predicate: () -> Boolean) {
val deadline = System.currentTimeMillis() + 15_000
while (!predicate()) { check(System.currentTimeMillis() < deadline) { "UI did not settle" }; Thread.sleep(80) }
}
}
Loading
Loading