Skip to content

Commit 09273f3

Browse files
authored
Fix Cowork editing and file responsiveness (#28)
Co-authored-by: Joseph Yaksich <gitcommit90@users.noreply.github.com>
1 parent 7a71e0b commit 09273f3

16 files changed

Lines changed: 170 additions & 36 deletions

CHANGELOG.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.0.20] - 2026-07-27
11+
12+
### Added
13+
14+
- Cowork's agent request, Notes, and Docs editors now support the same
15+
explicit speech-to-text mic control and bare Option/Alt shortcut as Chat
16+
and Quick Note.
17+
18+
### Fixed
19+
20+
- Skipper now renders with the product avatar in Cowork rather than a plain
21+
`S` initial.
22+
- Returning to Cowork after navigating away starts a fresh collaboration
23+
transport from the authoritative saved file. It cannot merge stale Yjs
24+
history into a new room and duplicate an agent's or user's document edits.
25+
- Long Cowork Notes stay scrollable while in Write mode.
26+
- Files selection now paints immediately. Recursive folder-tree loading is
27+
independent of the current directory request, removing repeated VM mirror
28+
work from ordinary file and folder clicks.
29+
30+
### Tests
31+
32+
- Browser coverage proves leaving and reopening a saved Cowork note retains
33+
exactly one copy of its content, and focused source/browser contracts cover
34+
Cowork dictation, Skipper identity, Notes scrolling, and non-blocking Files
35+
selection.
36+
1037
## [0.0.19] - 2026-07-27
1138

1239
### Fixed
@@ -556,7 +583,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
556583
notarization, stapled tickets, Gatekeeper verification, persistent
557584
Application Support, and isolated Apple container machines.
558585

559-
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.19...HEAD
586+
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.20...HEAD
587+
[0.0.20]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.20
560588
[0.0.19]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.19
561589
[0.0.18]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.18
562590
[0.0.17]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.17

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to
296296
| `PORT` | `8123` | HTTP/WebSocket control-plane port. |
297297
| `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. |
298298
| `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. |
299-
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.19` | Versioned channel-machine image contract. |
299+
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.20` | Versioned channel-machine image contract. |
300300

301301
### Agent-first JSON CLI
302302

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "1helm",
33
"productName": "1Helm",
4-
"version": "0.0.19",
4+
"version": "0.0.20",
55
"private": true,
66
"type": "module",
77
"license": "AGPL-3.0-only",

public/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@
3030
document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); });
3131
})();
3232
</script>
33-
<link rel="stylesheet" href="/app.css?v=85051ba52505" />
33+
<link rel="stylesheet" href="/app.css?v=766362520e61" />
3434
<link rel="stylesheet" href="/bundle.css" />
3535
<link rel="stylesheet" href="/excalidraw/index.css" />
3636
</head>
3737
<body class="h-screen w-screen overflow-hidden antialiased">
3838
<div id="app" class="h-full w-full"></div>
39-
<script type="module" src="/bundle.js?v=0520498da6f9"></script>
39+
<script type="module" src="/bundle.js?v=c2de13d4517b"></script>
4040
</body>
4141
</html>

src/client/app.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2761,7 +2761,9 @@ type BrowserSpeechRecognition = {
27612761
stop(): void;
27622762
};
27632763
type BrowserSpeechRecognitionConstructor = new () => BrowserSpeechRecognition;
2764-
let activeSpeech: { recognition: BrowserSpeechRecognition; input: HTMLTextAreaElement; button: HTMLButtonElement } | null = null;
2764+
type SpeechTextTarget = HTMLTextAreaElement | { value: () => string; replace: (value: string) => void; focus: () => void };
2765+
let activeSpeech: { recognition: BrowserSpeechRecognition; input: SpeechTextTarget; button: HTMLButtonElement } | null = null;
2766+
let focusedSpeechTarget: SpeechTextTarget | null = null;
27652767

27662768
function setListeningIndicator(listening: boolean): void {
27672769
let indicator = document.querySelector<HTMLElement>("[data-listening-indicator]");
@@ -2791,7 +2793,7 @@ function activeComposerInput(): HTMLTextAreaElement | null {
27912793
const parent = S.threadRoot ? String(S.threadRoot.id) : "root";
27922794
return document.querySelector<HTMLTextAreaElement>(`textarea[data-composer-parent="${parent}"]`);
27932795
}
2794-
async function toggleSpeechToText(input = activeComposerInput()): Promise<void> {
2796+
async function toggleSpeechToText(input: SpeechTextTarget | null = activeComposerInput(), explicitButton?: HTMLButtonElement): Promise<void> {
27952797
if (!input) return;
27962798
if (activeSpeech) {
27972799
const wasThisInput = activeSpeech.input === input;
@@ -2803,10 +2805,10 @@ async function toggleSpeechToText(input = activeComposerInput()): Promise<void>
28032805
await appAlert("Speech-to-text is not available in this browser. Try the current 1Helm desktop app or a browser with SpeechRecognition support; typing and attachments still work normally.");
28042806
return;
28052807
}
2806-
const button = input.closest(".composer-wrap, [data-quick-note]")?.querySelector<HTMLButtonElement>("[data-speech-toggle]");
2808+
const button = explicitButton || (input instanceof HTMLTextAreaElement ? input.closest(".composer-wrap, [data-quick-note]")?.querySelector<HTMLButtonElement>("[data-speech-toggle]") : null);
28072809
if (!button) return;
28082810
const recognition = new Recognition();
2809-
const original = input.value;
2811+
const original = input instanceof HTMLTextAreaElement ? input.value : input.value();
28102812
const joiner = original && !/\s$/.test(original) ? " " : "";
28112813
let finalTranscript = "";
28122814
recognition.continuous = true;
@@ -2819,9 +2821,12 @@ async function toggleSpeechToText(input = activeComposerInput()): Promise<void>
28192821
if (event.results[index]?.isFinal) finalTranscript += transcript;
28202822
else interim += transcript;
28212823
}
2822-
input.value = original + joiner + finalTranscript + interim;
2823-
input.selectionStart = input.selectionEnd = input.value.length;
2824-
input.dispatchEvent(new Event("input"));
2824+
const next = original + joiner + finalTranscript + interim;
2825+
if (input instanceof HTMLTextAreaElement) {
2826+
input.value = next;
2827+
input.selectionStart = input.selectionEnd = input.value.length;
2828+
input.dispatchEvent(new Event("input"));
2829+
} else input.replace(next);
28252830
};
28262831
recognition.onerror = (event: any) => {
28272832
const reason = String(event.error || "speech recognition failed");
@@ -2849,11 +2854,29 @@ async function toggleSpeechToText(input = activeComposerInput()): Promise<void>
28492854
}
28502855
}
28512856

2857+
/** Shared explicit mic control for textareas and Cowork's CodeMirror inputs.
2858+
* The bare Option/Alt shortcut targets the currently focused control too. */
2859+
export function mountSpeechToTextControl(input: SpeechTextTarget, label = "Toggle speech-to-text"): HTMLButtonElement {
2860+
const button = h("button", {
2861+
class: "grid h-8 w-8 shrink-0 place-items-center rounded text-muted hover:bg-hover hover:text-fg",
2862+
type: "button",
2863+
title: speechRecognitionAvailable() ? `${label} · tap Option/Alt to toggle` : "Speech-to-text is unavailable in this browser",
2864+
"aria-label": label,
2865+
"aria-pressed": "false",
2866+
dataset: { speechToggle: "" },
2867+
}, microphoneIcon()) as HTMLButtonElement;
2868+
button.onclick = () => { void toggleSpeechToText(input, button); };
2869+
return button;
2870+
}
2871+
2872+
export function setFocusedSpeechTarget(input: SpeechTextTarget | null): void { focusedSpeechTarget = input; }
2873+
28522874
function composer(parentId: number | null): HTMLElement {
28532875
const channel = S.channels.find((item) => item.id === S.channelId);
28542876
const humanOnly = ["collab", "human"].includes(channel?.kind || "");
28552877
const attachBar = h("div", { class: "flex flex-wrap gap-2 px-1 pt-1 empty:hidden" });
28562878
const input = h("textarea", { class: "max-h-44 min-h-[24px] w-full resize-none bg-transparent px-1 py-1 text-[15px] text-fg outline-none placeholder:text-faint", rows: 1, dataset: { composerParent: parentId == null ? "root" : String(parentId) }, placeholder: parentId ? "Reply…" : humanOnly ? "Message your coworkers…" : "Start a session — mention the resident agent or @skipper" }) as HTMLTextAreaElement;
2879+
input.addEventListener("focus", () => setFocusedSpeechTarget(input));
28572880
const mentionBox = h("div", { class: "absolute bottom-full left-0 right-0 z-20 mb-2 hidden max-h-[50vh] w-full max-w-sm overflow-y-auto overflow-hidden rounded-lg border border-line bg-surface shadow-xl sm:right-auto sm:w-72" });
28582881
const draftKey = `1helm.draft.${S.me.id}.${S.channelId}.${parentId == null ? "root" : parentId}`;
28592882
const savedDraft = localStorage.getItem(draftKey);
@@ -3266,8 +3289,10 @@ window.addEventListener("keyup", (event) => {
32663289
const isSingleTap = altTapOnly && performance.now() - altTapStarted < 800;
32673290
altTapOnly = false;
32683291
if (!isSingleTap || !document.hasFocus()) return;
3269-
const input = activeComposerInput();
3270-
if (input && !input.disabled && input.offsetParent !== null) void toggleSpeechToText(input);
3292+
const input = focusedSpeechTarget || activeComposerInput();
3293+
if (!input) return;
3294+
if (input instanceof HTMLTextAreaElement && (input.disabled || input.offsetParent === null)) return;
3295+
void toggleSpeechToText(input);
32713296
});
32723297
window.addEventListener("blur", () => { altTapOnly = false; });
32733298
window.matchMedia("(min-width: 768px)").addEventListener("change", (event) => { if (event.matches && S.mobileMenuOpen) closeMobileMenu(); });

src/client/channel.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa
480480
const info = h("aside", { class: "hidden min-h-0 w-60 shrink-0 overflow-y-auto border-l border-line bg-raised/35 p-4 xl:block", dataset: { fileMetadata: "" } });
481481
const crumbs = h("nav", { class: "flex min-w-0 flex-1 items-center gap-1 overflow-x-auto font-mono text-xs", "aria-label": "File breadcrumbs", dataset: { fileBreadcrumbs: "" } });
482482
const fileInput = h("input", { type: "file", multiple: true, class: "hidden" }) as HTMLInputElement;
483+
let directoryCache: ChannelFile[] | null = null;
483484
const open = (entry: ChannelFile): void => {
484485
if (entry.kind === "directory") { currentPath = entry.path; selected = null; void load(); return; }
485486
const target = coworkPath(entry.path);
@@ -535,16 +536,24 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa
535536
selected.kind === "file" ? h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void downloadAuthenticatedFile(`/api/channels/${channelId}/files/content?path=${encodeURIComponent(selected!.path)}&download=1`, selected!.name); } }, "Download") : null,
536537
h("button", { class: "btn-ghost text-xs text-danger", type: "button", onclick: () => { void mutate("delete"); } }, "Delete")));
537538
};
538-
const load = async (): Promise<void> => {
539+
const refreshDirectories = async (): Promise<void> => {
540+
try {
541+
directoryCache = (await api<{ directories: ChannelFile[] }>(`/api/channels/${channelId}/files/directories`)).directories;
542+
drawTree(directoryCache);
543+
} catch { /* the current directory remains usable while the tree retries */ }
544+
};
545+
const redrawSelection = (): void => {
546+
main.querySelectorAll<HTMLElement>("[data-file-path]").forEach((node) => node.classList.toggle("is-selected", node.dataset.filePath === selected?.path));
547+
drawInfo();
548+
};
549+
const load = async (options: { refreshTree?: boolean } = {}): Promise<void> => {
539550
const requestedPath = currentPath;
540551
try {
541-
const [result, folders] = await Promise.all([
542-
api<{ path?: string; files: ChannelFile[] }>(`/api/channels/${channelId}/files?path=${encodeURIComponent(requestedPath)}`),
543-
api<{ directories: ChannelFile[] }>(`/api/channels/${channelId}/files/directories`),
544-
]);
552+
const result = await api<{ path?: string; files: ChannelFile[] }>(`/api/channels/${channelId}/files?path=${encodeURIComponent(requestedPath)}`);
545553
if (requestedPath !== currentPath) return;
546554
currentPath = result.path ?? requestedPath; heading.textContent = `/workspace${currentPath ? `/${currentPath}` : ""}`; main.dataset.fileDirectory = currentPath || "/";
547-
drawTree(folders.directories);
555+
if (directoryCache) drawTree(directoryCache);
556+
else void refreshDirectories();
548557
clear(crumbs);
549558
const segments = currentPath ? currentPath.split("/") : [];
550559
const addCrumb = (label: string, path: string): void => {
@@ -558,22 +567,22 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa
558567
if (!files.length) main.append(empty(result.files.length ? "No matches" : "This folder is empty", result.files.length ? "Try a different search." : "Create a folder or file, upload something, or let the resident agent add it."));
559568
else main.append(h("div", { class: "file-grid", role: "list" }, ...files.map((entry) => h("button", {
560569
class: `file-grid-item ${selected?.path === entry.path ? "is-selected" : ""}`, type: "button", role: "listitem", dataset: { filePath: entry.path, fileKind: entry.kind },
561-
onclick: () => { selected = entry; void load(); }, ondblclick: () => open(entry),
570+
onclick: () => { selected = entry; redrawSelection(); }, ondblclick: () => open(entry),
562571
}, h("span", { class: `file-grid-icon ${entry.kind === "directory" ? "is-folder" : "is-file"}` }, workspaceIcon(entry, 27)), h("span", { class: "min-w-0 flex-1 text-left" }, h("span", { class: "block truncate text-sm font-semibold text-fg" }, entry.name), h("span", { class: "mt-0.5 block truncate text-[11px] text-muted" }, entry.kind === "directory" ? "Folder" : `${formatBytes(entry.size)} · ${timeLabel(entry.modified)}`)), h("span", { class: "file-grid-kind" }, entry.kind === "directory" ? "Folder" : (entry.name.split(".").pop()?.toUpperCase() || "File"))))));
563572
drawInfo(); status.textContent = `${result.files.length} item${result.files.length === 1 ? "" : "s"}`;
564573
} catch (error) { panelError(main, error); }
565574
};
566575
search.oninput = () => { filter = search.value.trim().toLowerCase(); void load(); };
567576
const sortSelect = h("select", { class: "field h-9 w-auto min-w-28 text-xs", "aria-label": "Sort files", onchange: (event: Event) => { sort = (event.target as HTMLSelectElement).value as typeof sort; void load(); } }, h("option", { value: "name" }, "Name"), h("option", { value: "modified" }, "Modified"), h("option", { value: "size" }, "Size"));
568-
const newFolder = async (): Promise<void> => { const name = await appPrompt("Folder name"); if (!name) return; try { await api(`/api/channels/${channelId}/files/directories`, { body: { path: currentPath, name } }); await load(); } catch (error) { status.textContent = (error as Error).message; } };
577+
const newFolder = async (): Promise<void> => { const name = await appPrompt("Folder name"); if (!name) return; try { await api(`/api/channels/${channelId}/files/directories`, { body: { path: currentPath, name } }); directoryCache = null; await load(); } catch (error) { status.textContent = (error as Error).message; } };
569578
const newFile = async (): Promise<void> => { const name = await appPrompt("File name", "untitled.md"); if (!name) return; try { await api(`/api/channels/${channelId}/files/entries`, { body: { parent: currentPath, name, content: "" } }); await load(); } catch (error) { status.textContent = (error as Error).message; } };
570579
fileInput.onchange = async () => { const chosen = Array.from(fileInput.files || []); if (!chosen.length) return; status.textContent = `Uploading ${chosen.length} item${chosen.length === 1 ? "" : "s"}…`; try { for (const file of chosen) { const upload = await uploadFile(file); await api(`/api/channels/${channelId}/files/upload`, { body: { ...upload, path: currentPath } }); } fileInput.value = ""; await load(); } catch (error) { status.textContent = (error as Error).message; } };
571580
root.append(
572581
h("header", { class: "flex min-h-14 flex-wrap items-center gap-2 border-b border-line px-3 py-2 sm:px-4" }, h("span", { class: "text-accent" }, icon("folderOpen", 20)), heading, h("div", { class: "flex-1" }), status, h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void newFile(); } }, icon("plus", 14), "New file"), h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void newFolder(); } }, icon("folder", 14), "New folder"), h("button", { class: "btn-primary text-xs", type: "button", onclick: () => fileInput.click() }, "Upload"), fileInput),
573582
h("div", { class: "flex min-h-0 flex-1" },
574583
h("aside", { class: "hidden min-h-0 w-60 shrink-0 flex-col border-r border-line bg-raised/35 md:flex" }, h("div", { class: "border-b border-line p-3" }, h("div", { class: "eyebrow text-muted" }, "Folders")), tree),
575584
h("section", { class: "flex min-h-0 min-w-0 flex-1 flex-col" }, h("div", { class: "flex flex-wrap items-center gap-2 border-b border-line bg-raised/25 px-3 py-2" }, crumbs, h("div", { class: "w-full sm:w-48" }, search), sortSelect), main), info));
576-
fileBrowserSurfaces.set(channelId, { node: root, reload: load });
585+
fileBrowserSurfaces.set(channelId, { node: root, reload: async () => { directoryCache = null; await load(); } });
577586
clear(container); container.append(root); void load();
578587
}
579588

src/client/cowork-editors.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type MountedEditor = {
3434
focus: () => void;
3535
destroy: () => void;
3636
getContent?: () => string;
37+
replaceContent?: (content: string) => void;
3738
format?: (prefix: string, suffix?: string, placeholder?: string) => void;
3839
selection?: () => { from: number; to: number };
3940
};
@@ -87,6 +88,10 @@ export function mountCodeMirror(
8788
focus: () => view.focus(),
8889
destroy: () => view.destroy(),
8990
getContent: () => view.state.doc.toString(),
91+
replaceContent: (content) => {
92+
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content }, selection: { anchor: content.length } });
93+
view.focus();
94+
},
9095
format: (prefix, suffix = prefix, placeholder = "text") => {
9196
const range = view.state.selection.main;
9297
const selected = view.state.sliceDoc(range.from, range.to) || placeholder;

0 commit comments

Comments
 (0)