Skip to content

Commit b04f8b8

Browse files
feat(embed): host embedded windows as owned top-level popups
Embedded apps stay top-level (borderless popup owned by our window, kept over the host's client area) instead of WS_CHILD, so raw-input apps such as Blender and games receive the keyboard. Foreign Win32 calls that synchronise on the shared input queue run on a per-embed EmbeddedWindowWorker; click and activation hand-offs follow one rule so focus never ping-pongs. main.cpp sets AA_DontCreateNativeWidgetSiblings so a native host no longer turns every ancestor sibling into an HWND. Adds live SendInput tests (opt-in via NOTEPADAI_INTERACTIVE_TESTS) with a raw-input target and an optional real Blender run. Also: fix misc-misplaced-const in restoreExact; README mentions ask_user forms and their note box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cb0f4e6 commit b04f8b8

18 files changed

Lines changed: 2393 additions & 295 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ You shouldn't have to leave your editor to talk to an AI, run git, or open a she
1010

1111
NotepadAI speaks the Agent Client Protocol (ACP) over stdio. Claude Code and Codex ship as factory agents and can be edited or deleted in Settings. You can also add any other ACP-compatible agent, including Gemini, Auggie, or your own command. Agents read and write files, run terminal commands, and see your workspace context, so they work on the same code you do. Start a session from the AI menu or by right-clicking a folder in the file tree.
1212

13-
In a chat, a finished turn folds into a single "Worked for 2m14s" row; click it to bring back the tool calls and intermediate messages. Tool calls show how long they took. Claude Code background tasks and the wake-ups or cron jobs it schedules for itself appear under the transcript, with a Stop link for tasks that can be stopped. When the agent offers `/advisor`, an Advisor checkbox in the composer turns it on and off.
13+
In a chat, a finished turn folds into a single "Worked for 2m14s" row; click it to bring back the tool calls and intermediate messages. Tool calls show how long they took. Claude Code background tasks and the wake-ups or cron jobs it schedules for itself appear under the transcript, with a Stop link for tasks that can be stopped. When the agent offers `/advisor`, an Advisor checkbox in the composer turns it on and off. When an agent needs a decision, its questions appear as a form in the chat with the suggested answer preselected, and a note box at the end takes anything else you want to add.
1414

1515
There is also a Goal Agent: pick an ACP judge (Claude Code, Codex, Opencode) or a Custom API endpoint that speaks Anthropic's Messages API. You set success criteria and it drives the working agent until those criteria hold, or attach a goal to an existing conversation. Criteria presets can be saved globally, or into the current project's `.agents/.goals` folder so they travel with the repo; files already in that folder show up in the load list. A judge that needs a human action, or that hits the iteration cap with the criterion still unmet, stops and hands the criterion back instead of marking the goal done. With Prefix /goal checked, the first criterion is sent immediately when the agent supports `/goal`. If a reply is still running and the agent does not support `/goal`, that goal waits in the send queue so the current reply is not stopped. Later criteria wait until the previous is achieved. Send with Goal defaults to the agent's own `/goal` command instead: each criterion goes out on its own, with no separate judge. A `/goal` typed in the composer is sent while a turn is still running only when the agent supports that command; otherwise it waits in the queue. A goal is not judged, and auto compact does not run, while the agent still has background tasks running or a wake-up turn pending. When a goal is achieved, the session can compact automatically. Prompts sent while an agent is still working are queued and delivered when it finishes. AI chats group by project, with a session strip to switch conversations in the same dock. The composer can ask the agent to work in a new git worktree. When you commit, AI can write the commit message from your staged diff. That generator (and other LLM calls) can talk to an OpenAI-compatible endpoint or Anthropic's Messages API. Leave the commit box empty (when not amending) and Commit lets you pick an ACP agent to do the commit. Protocol details and how to wire up a custom ACP agent are in [doc/AcpAgents.md](doc/AcpAgents.md).
1616

‎src/CMakeLists.txt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,8 @@ qt_add_executable(NotepadAI
570570
EmbeddedWindowWin32.h
571571
EmbeddedWindowWin32.cpp
572572
EmbeddedWindowEnumeration.cpp
573+
EmbeddedWindowWorker.h
574+
EmbeddedWindowWorker.cpp
573575
NativeWindowHost.h
574576
NativeWindowHost.cpp
575577
PendingCloseState.h
@@ -699,7 +701,7 @@ if(WIN32)
699701
# crypt32/dbghelp: existing deps. ws2_32: winsock for the SSH transport's
700702
# raw socket (Libssh2Transport.cpp calls getaddrinfo/connect/closesocket).
701703
# dwmapi: filters cloaked compositor windows from the Embed menu.
702-
target_link_libraries(NotepadAI PRIVATE crypt32 dbghelp ws2_32 dwmapi)
704+
target_link_libraries(NotepadAI PRIVATE crypt32 dbghelp ws2_32 dwmapi comctl32)
703705
elseif(APPLE)
704706
target_sources(NotepadAI PRIVATE ai/CredentialStore_Mac.mm)
705707
target_link_libraries(NotepadAI PRIVATE "-framework Security" "-framework CoreFoundation")

‎src/EmbeddedWindowController.cpp‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ void EmbeddedWindowController::poll()
152152
for (Embed *embed : snapshot) {
153153
if (embed->closePending || !platTargetOwnsToken(embed->state, embed->token))
154154
closeEmbeddedTab(embed, ReleaseMode::FailSafeDetach);
155+
else
156+
platTick(embed->token);
155157
}
156158
updateWatchdog();
157159
}

‎src/EmbeddedWindowController.h‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,17 @@ class EmbeddedWindowController : public QObject
9393
virtual quintptr platCreateTab(quintptr token, const QString &title) = 0;
9494
// Tag the foreign window with `token`. False if refused/already tagged.
9595
virtual bool platMarkWindow(quintptr handle, quintptr token) = 0;
96-
// Reparent + restyle the foreign window into the host, then attach/sync.
96+
// Restyle the foreign window into a popup owned by the host's top-level and
97+
// place it over the host, then attach/sync.
9798
virtual bool platPrepareAndAttach(quintptr token, const EmbeddedWindowWin32::NativeWindowState &state,
9899
quintptr hostHandle) = 0;
99100
// Read-only ownership guard: the expected target still carries our token
100101
// (also validates handle + pid + tid). This gates every HWND mutation.
101102
virtual bool platTargetOwnsToken(
102103
const EmbeddedWindowWin32::NativeWindowState &state, quintptr token) = 0;
103-
// Read-only target-specific hierarchy guard: the expected HWND is currently
104-
// a direct child of this embed's native host, independent of token state.
104+
// Read-only target-specific hierarchy guard: the expected HWND is still
105+
// embedded (owned by the host's top-level, so it would be destroyed with
106+
// it), independent of token state.
105107
virtual bool platExpectedTargetAttached(
106108
const EmbeddedWindowWin32::NativeWindowState &state, quintptr token) = 0;
107109
// Read-only final destruction guard: the host still parents any live foreign
@@ -126,6 +128,9 @@ class EmbeddedWindowController : public QObject
126128
// Show the user the "cannot detach; tab kept open" feedback.
127129
virtual void platNotifyCloseVetoed() = 0;
128130

131+
// Periodic (watchdog) check of a live, owned embed. Default: nothing.
132+
virtual void platTick(quintptr token) { Q_UNUSED(token) }
133+
129134
// Start/stop the retry watchdog. Base impl toggles a coarse timer; override
130135
// is unnecessary for tests (they call poll() directly, watchdog stays idle).
131136
virtual void updateWatchdog();

‎src/EmbeddedWindowManager.cpp‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ EmbeddedWindowManager::EmbeddedWindowManager(DockedEditor *dockedEditor, QObject
3030
, m_dockedEditor(dockedEditor)
3131
{
3232
connect(m_dockedEditor, &DockedEditor::previewTabActivated, this, [this](QWidget *widget) {
33+
// Choosing an embed tab gives the embed the keyboard, like an editor:
34+
// Qt focus on its host, then the (coalesced) activation.
35+
for (const Tab *tab : std::as_const(m_tabs)) {
36+
if (tab->host && tab->host.data() == widget) {
37+
widget->setFocus(Qt::OtherFocusReason);
38+
break;
39+
}
40+
}
3341
m_focusRouter.request(widget);
3442
});
3543
}
@@ -86,7 +94,7 @@ quintptr EmbeddedWindowManager::platCreateTab(quintptr token, const QString &tit
8694
const QPointer<NativeWindowHost> hostGuard(host);
8795
m_focusRouter.registerHost(host, [hostGuard]() {
8896
if (hostGuard)
89-
hostGuard->restoreForeignFocus();
97+
hostGuard->activateForeign();
9098
});
9199
host->setFocusRequestCallback([managerGuard, hostGuard]() {
92100
if (managerGuard && hostGuard)
@@ -147,8 +155,7 @@ bool EmbeddedWindowManager::platExpectedTargetAttached(
147155
{
148156
Tab *tab = m_tabs.value(token);
149157
return tab && tab->host
150-
&& EmbeddedWindowWin32::isDirectChildOfHost(
151-
state.handle, tab->host->nativeHandle());
158+
&& EmbeddedWindowWin32::isEmbeddedIn(state.handle, tab->host->nativeHandle());
152159
}
153160

154161
bool EmbeddedWindowManager::platHostRetainsForeignChild(quintptr token)
@@ -161,13 +168,33 @@ bool EmbeddedWindowManager::platHostRetainsForeignChild(quintptr token)
161168
bool EmbeddedWindowManager::platRestoreExact(
162169
const EmbeddedWindowWin32::NativeWindowState &state, quintptr token)
163170
{
164-
return EmbeddedWindowWin32::restoreExact(state, token);
171+
Tab *tab = m_tabs.value(token);
172+
if (tab && tab->host)
173+
tab->host->pauseForeignCalls();
174+
const bool released = EmbeddedWindowWin32::restoreExact(state, token);
175+
if (!released && tab && tab->host)
176+
tab->host->resumeForeignCalls();
177+
return released;
165178
}
166179

167180
bool EmbeddedWindowManager::platFailSafeDetach(
168181
const EmbeddedWindowWin32::NativeWindowState &state, quintptr token)
169182
{
170-
return EmbeddedWindowWin32::failSafeDetach(state, token);
183+
Tab *tab = m_tabs.value(token);
184+
if (tab && tab->host)
185+
tab->host->pauseForeignCalls();
186+
const bool released = EmbeddedWindowWin32::failSafeDetach(state, token);
187+
if (!released && tab && tab->host)
188+
tab->host->resumeForeignCalls();
189+
return released;
190+
}
191+
192+
void EmbeddedWindowManager::platTick(quintptr token)
193+
{
194+
// Self-heal geometry if the embedded app resized itself (pure reads unless
195+
// the rect differs).
196+
if (Tab *tab = m_tabs.value(token); tab && tab->host && tab->host->isVisible())
197+
tab->host->sync();
171198
}
172199

173200
void EmbeddedWindowManager::platDetachHost(quintptr token)

‎src/EmbeddedWindowManager.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class EmbeddedWindowManager final : public EmbeddedWindowController
4747
const EmbeddedWindowWin32::NativeWindowState &state, quintptr token) override;
4848
bool platHostRetainsForeignChild(quintptr token) override;
4949
bool platRestoreExact(const EmbeddedWindowWin32::NativeWindowState &state, quintptr token) override;
50+
void platTick(quintptr token) override;
5051
bool platFailSafeDetach(const EmbeddedWindowWin32::NativeWindowState &state, quintptr token) override;
5152
void platDetachHost(quintptr token) override;
5253
void platClearHostCallback(quintptr token) override;

0 commit comments

Comments
 (0)