feat(mobile): native PostHog app for tasks (#102367)
Co-authored-by: Dylan Martin <dmarticus@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: tests-posthog[bot] <250237707+tests-posthog[bot]@users.noreply.github.com>
No files matched your search
@@ -0,0 +1,26 @@
|
||||
# Excluded from EAS Build upload archives (eas-cli reads this at the git root).
|
||||
# When present, this file replaces .gitignore for the upload in some modes, so
|
||||
# it must re-list the heavy ignored directories. Only list untracked paths:
|
||||
# pruning a tracked file would break the remote build.
|
||||
.claude/worktrees/
|
||||
.flox/cache/
|
||||
.flox/run/
|
||||
.flox/lib/
|
||||
.flox/log/
|
||||
node_modules/
|
||||
.vite/
|
||||
/products/desktop/apps/mobile/ios/
|
||||
/products/desktop/apps/mobile/android/
|
||||
/products/desktop/apps/mobilehog/ios/
|
||||
/products/desktop/apps/mobilehog/android/
|
||||
.parcel-cache/
|
||||
.postgres-backups/
|
||||
.typegen/
|
||||
.venv/
|
||||
.expo/
|
||||
.turbo/
|
||||
__pycache__/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
rust/target/
|
||||
@@ -0,0 +1,188 @@
|
||||
# posthog release checks
|
||||
|
||||
The posthog mobile app lives in `products/desktop/apps/mobilehog`.
|
||||
Its bundle ID is `com.posthog.mobilehog`.
|
||||
The build workflows for `apps/mobile` target a different app.
|
||||
Build and install a new native binary to update the displayed app name to `posthog`.
|
||||
Keep the bundle ID and storage keys stable so updates preserve existing installations.
|
||||
|
||||
## Updates
|
||||
|
||||
The app has its own EAS project ID, but over-the-air updates remain disabled.
|
||||
Store builds use the bundled JavaScript.
|
||||
|
||||
To enable updates, run `eas update:configure` from `products/desktop/apps/mobilehog` with access to the correct Expo organization.
|
||||
Check that `extra.eas.projectId` and `updates.url` refer to that project, then set `updates.enabled` to `true`.
|
||||
Build and install a new binary before publishing an update to its channel.
|
||||
Do not reuse the older mobile app's project ID.
|
||||
|
||||
## Account changes
|
||||
|
||||
On iOS, Get started opens a fresh browser sign-in so users can choose a PostHog account.
|
||||
The app saves the selected account's session in secure device storage and restores it on restart.
|
||||
Each signed-in account has a separate query client.
|
||||
Logout clears queries, task transcripts, run watchers, and composer state.
|
||||
Repository selection and seen reports use storage keys scoped to the host, project, and user.
|
||||
The old unscoped values are not loaded.
|
||||
Appearance remains a device preference.
|
||||
Late authentication results cannot restore a session after logout.
|
||||
Settings > Project lists projects available to the current sign-in.
|
||||
The selected project persists across restarts and token refreshes.
|
||||
Switching projects clears the previous project's cached data and navigation state.
|
||||
Cloud sign-in lists only projects included in the OAuth grant.
|
||||
|
||||
## Tasks
|
||||
|
||||
The drawer starts with the signed-in user's cloud tasks in the selected project, including tasks started from Desktop.
|
||||
The drawer shows one task list across all spaces, without space names or space filters.
|
||||
Tasks use system text, relative timestamps, and a running indicator. Task rows have no read markers or status filters.
|
||||
The active list excludes archived tasks. Task list options opens the archive view.
|
||||
Task lists use the server's most recent activity order and support loading older pages.
|
||||
The search button opens a separate screen with its input above the keyboard.
|
||||
Search matches task titles, descriptions, and task numbers on the server, across all of the user's spaces in the selected project.
|
||||
Before a query, Search shows up to eight recent searches, not recent tasks. Completed searches stay on this device and are scoped to the signed-in account and project.
|
||||
Local Desktop runs remain on Desktop. Archived tasks stay outside the task list.
|
||||
Pull to refresh or return to the app to update tasks.
|
||||
|
||||
New tasks omit the channel so the server creates them in the user's personal space.
|
||||
Accessible tasks owned by the user remain visible and can receive replies, regardless of their space.
|
||||
The mobile app does not show space names in the drawer, search, composer, or Activity.
|
||||
|
||||
## Push notifications
|
||||
|
||||
Task completion from Desktop or mobile uses the same server-side owner notification path. Delivery requires a signed-in account, permission to show notifications, an Expo push token registered with the server, and working iOS push credentials for the app's EAS project.
|
||||
The app registers after sign-in and retries when it becomes active. Notification permissions stay in iOS settings. Expo push delivery can be tested on a supported iOS simulator or a physical device; it needs a push-enabled native build.
|
||||
To check cross-device delivery, start a cloud task from Desktop, put the mobile app in the background, and confirm that the completion alert arrives and opens that task. Server delivery metrics count Expo acceptance, not confirmed device delivery.
|
||||
|
||||
## Self-driving and task conversations
|
||||
|
||||
Self-driving lists reports needing attention and PRs ready for review where the signed-in user is a suggested reviewer.
|
||||
Its status control filters All active, Needs attention, Review PR, Resolved, or Dismissed before pagination. Newest first is the default; the sort menu also offers Priority.
|
||||
Opening a report marks it read for the current account and syncs with Desktop. Read actions clear the new-item indicators without dismissing the reports.
|
||||
Tapping a report in the list opens a separate full-detail screen. Report actions stay in its options menu. The visible Triage button opens the optional decision deck. Existing PRs remain in the report list.
|
||||
The sync notice sits below the deck and cannot cover a card.
|
||||
Dismiss changes the report state for the project. Activity read state is stored on the server.
|
||||
Both lists support refresh and loading older items.
|
||||
Read actions appear only when visible items are unread.
|
||||
Empty lists have a centered explanation. Loading and request failures have separate states.
|
||||
The empty Self-driving list does not show report instructions or review actions.
|
||||
|
||||
Task conversations keep the header and reply box outside the scrolling messages.
|
||||
Conversations have no new-chat shortcut; use the drawer to start another task. The reply box has no top divider.
|
||||
The + button in both composers selects up to ten photos at once from the device library. Previews scroll horizontally.
|
||||
The composer previews selected photos, lets the user remove them, and sends images with the task message.
|
||||
Image-only messages get a short prompt. Images total no more than 5 MB; unsupported iOS formats convert to JPEG.
|
||||
New tasks keep the composer open until an image message is accepted, so a failed send keeps the draft.
|
||||
Select more than three photos, remove one preview, and restart the app. Confirm that the remaining draft photos return.
|
||||
This uses native image picker and file system modules, so install a fresh native build before testing attachments.
|
||||
Images can be expanded. Saved insights and SQL references render charts or tables; unsupported insight types link to PostHog.
|
||||
Mobile run requests set `X-PostHog-Client-Platform: mobile` for the PR footer.
|
||||
The backend carries that value to the agent as run state. It does not change GitHub authorship or commit signing.
|
||||
The Mobile footer needs the backend and agent changes to be deployed.
|
||||
|
||||
Before sharing a build:
|
||||
|
||||
- Sign in to one account in Safari, then tap Get started in the app and sign in to another account.
|
||||
Confirm that the app uses the selected account and keeps it after a restart.
|
||||
Check saved-password autofill on a device with saved PostHog credentials.
|
||||
- Sign in with access to multiple projects. Open Settings > Project, search, and select another project.
|
||||
Confirm that tasks, spaces, reports, and repository choices belong to the selected project.
|
||||
Restart the app and confirm that it keeps the selected project.
|
||||
- Switch projects while a token refresh or task request is pending.
|
||||
The old project must not return, and old results must not appear in the new project.
|
||||
- Disconnect the network before opening the project list or selecting a project.
|
||||
Confirm that the app shows an error, keeps the current project, and permits another attempt.
|
||||
- Sign in, open a task and a report, select a repository, then log out and sign in with another account.
|
||||
No data or selection from the first account should appear.
|
||||
- Log out while token refresh or a task command is pending.
|
||||
The old session must not return, and the command must not start work under the new account.
|
||||
- Start a cloud task in Desktop, then sign in to the same account and project on mobile.
|
||||
Confirm that Tasks shows it alongside tasks from other spaces and excludes tasks by other users.
|
||||
Open search with the sidebar button. Find a task from a different space and reply to it.
|
||||
Search for an older task by title, description text, and task number, then open a result.
|
||||
Confirm that the keyboard leaves the search field and results visible. Close search and confirm that the task list still spans all spaces.
|
||||
Load older pages. Check loading, empty, and error states. Confirm that no space names or picker appear.
|
||||
- Start a task from mobile and confirm that Desktop shows it in your personal space.
|
||||
Switch projects and confirm that tasks still belong to the selected project.
|
||||
- Create a task in Desktop while mobile is in the background, then return to mobile.
|
||||
Confirm that the task appears. Disconnect the network and check that refresh shows an error with Retry.
|
||||
- Tap the login button twice quickly.
|
||||
Only one login flow should start.
|
||||
- Open Self-driving with reports for different suggested reviewers. Only your reports should appear. Select Priority and confirm that P0 appears first.
|
||||
Open a report, mark loaded reports read, and confirm that the drawer indicator clears. Restart and check read state.
|
||||
Dismiss a report and confirm it leaves the project inbox. Disconnect the network and check that failed actions permit another attempt.
|
||||
- Open Activity with no items, then with only read items. Confirm that no mark-as-read action appears.
|
||||
Mark one unread item read, then mark the remaining visible items read. Confirm that the action disappears and the badge updates.
|
||||
Load older items. New activity that arrives during a read action must remain unread.
|
||||
- Open Self-driving with no reports. Confirm that the empty state has no read or triage action and no report instructions.
|
||||
Disconnect the network and refresh. Confirm that the screen shows a request error with Retry, rather than a successful empty state.
|
||||
- Open a task with an uploaded image, a Markdown image, and a saved insight. Expand the images and read chart values.
|
||||
Reopen the task and check that stored attachments still load. Check an unavailable image and insight, then retry.
|
||||
- Start a new task with a selected photo and text. Reply to a Desktop task with a photo but no text.
|
||||
Confirm that the agent receives each image, both conversations render it, and the picker stays available after a canceled selection.
|
||||
Remove a photo before sending and confirm that it is not sent. Try an image larger than 5 MB, then disconnect the network.
|
||||
Confirm that the draft stays available with an error rather than sending text without the image.
|
||||
- In a long conversation, scroll up while the agent works. Open and close the keyboard, send a reply, and use Latest message.
|
||||
Messages must remain clear of the header and reply box, without a large blank space or forced scrolling while reading history.
|
||||
- Start a task from mobile and let it create a PR. Confirm its footer says PostHog Mobile and its GitHub author is unchanged.
|
||||
- Open an MCP app from a task result, enter fullscreen, then return inline.
|
||||
The content must load in each view, and app controls must still work.
|
||||
- Check the login button on a device without Liquid Glass.
|
||||
Its orange background must remain visible.
|
||||
|
||||
Native checks need an iOS simulator or a device.
|
||||
Type checks alone do not validate WebView messages, native appearance, or cloud login.
|
||||
|
||||
## Rich content, dictation, and report controls
|
||||
|
||||
- Sign out and sign in to grant insight access. Open a saved insight and a SQL chart with multiple lines.
|
||||
Open a report with attached charts. Check loading, data, unavailable content, and Retry.
|
||||
- Open Mermaid flow and sequence diagrams in a stored conversation. Check light and dark appearance.
|
||||
Check an invalid diagram and Show diagram source. Diagrams must not load external content or run links.
|
||||
- Open a cloud attachment after returning to an older task. Retry a failed image load and expand the image.
|
||||
- In Self-driving, check all four sort orders across two pages. Open a report directly from the list.
|
||||
Start triage from the options menu. Mark loaded reports as read and confirm that unloaded reports do not change.
|
||||
- Check the compact and expanded composer with the keyboard open and closed.
|
||||
Check a narrow iPhone screen, a long model name, photos, dictation, and a task that is still working.
|
||||
- Dictate into a new task and an existing reply. Stop manually, then repeat and wait for silence.
|
||||
Check that each transcript appears once and stays editable before sending.
|
||||
Deny permission, cancel dictation, leave the screen, and put the app in the background.
|
||||
The microphone must stop, the existing text must remain, and the app must permit another attempt.
|
||||
|
||||
Dictation needs a new native build. Existing sign-ins need renewed authorization for saved insights.
|
||||
The [app README](../../products/desktop/apps/mobilehog/README.md) covers setup and feature limits.
|
||||
|
||||
## Drafts and recovery
|
||||
|
||||
- Write a new-task draft and a reply with a photo. Close and reopen the app. Confirm that both drafts remain in their own composers. Send one; only that draft clears.
|
||||
- Switch projects and accounts with a draft open. The next account must not show it. Sign out and confirm that draft files and cached conversations are removed.
|
||||
- Open tasks and Self-driving, then disconnect the network. Saved content remains readable, drafts remain editable, and Send is disabled. Reconnect; the app must not send the draft by itself.
|
||||
- Interrupt a send. The draft must remain. Check for server acceptance before retrying when the result is uncertain.
|
||||
- Rename, archive, and restore a task. Confirm the same state in Desktop and use each status filter. Archived running tasks must keep running.
|
||||
- Mark a report read on mobile and check Desktop, then reverse the direction. Mark unread, dismiss, undo, and restore from the Dismissed status filter. Another user's read state must not change.
|
||||
- Search task and report text. Only Tasks and Self-driving tabs appear. A result opens its task or report.
|
||||
- Open task options. Only Rename and Archive (or Restore) appear. Internal tasks and sandbox image-builder tasks stay out of the task list.
|
||||
- Use large text and VoiceOver to operate task menus, search tabs, the composer, and report actions. Check reduced motion and a narrow device in both appearances.
|
||||
- Install a preview build, publish a compatible test update, and use Settings to apply it. A native-module change must produce a different fingerprint and require a new build.
|
||||
- In the configured telemetry project, confirm that a test failure resolves to source code. Inspect the event payload: no prompt, image, token, search text, response body, or exception text may be present.
|
||||
|
||||
## Model selection and loading
|
||||
|
||||
- Open a running task, select another model from the same provider, and send a reply. Confirm the agent accepts the model and reasoning setting before it receives the reply.
|
||||
- Reject a model change and confirm the message stays unsent and the draft stays available. Repeat with a Pi task from Desktop.
|
||||
- Select different models in two tasks and a new-task draft. Move between them and confirm that each composer keeps its selection.
|
||||
- Open a task, return to the list, then open it again within one minute. Confirm the conversation remains visible and the live stream is reused.
|
||||
- Open a report from the list and Search. Confirm both open the full report screen, with triage available only from the Self-driving menu.
|
||||
- Delay report read-state requests. Confirm loaded reports appear before those requests finish.
|
||||
|
||||
Deploy the report read-state migration and API, and task notification title before distributing the matching mobile build.
|
||||
Mobile PR footers also require the task worker and agent image update. Existing runs can retain their previous agent image.
|
||||
|
||||
## Read state and chat rendering
|
||||
|
||||
- Mark a task update and a comment notification read. Both indicators and the Activity count clear. Fail a read request and confirm the indicator returns with a dismissible alert.
|
||||
- Open a task from Recent Tasks. Its loaded activity clears; an update newer than the loaded conversation stays unread. Read icons are neutral and unread icons are yellow, as on Desktop.
|
||||
- Make the report read-state endpoint unavailable. Mark a report read, restart, and confirm it stays read locally. Restore the endpoint and confirm pending changes sync. Switch accounts and confirm pending changes remain separate.
|
||||
- Open a long conversation. Recent messages appear before older history is requested. Load older messages across an agent message and a tool-update boundary; text must join correctly without duplicate messages or lost live updates.
|
||||
- Render a Markdown table, nested list, inline and plain URL, image, insight, and Mermaid diagram. Links stay within their sentences; wide tables scroll horizontally.
|
||||
- Deny dictation permission. The Settings dialog can be closed, and no red warning remains in the composer. Other composer errors can be dismissed without deleting the draft.
|
||||
@@ -194,6 +194,48 @@ class TestPushNotifications(APIBaseTest):
|
||||
self.assertEqual(payload[0]["body"], "task done")
|
||||
self.assertEqual(payload[0]["data"], {"taskId": "t1", "taskRunId": "r1"})
|
||||
|
||||
@patch("posthog.push_notifications.requests.post")
|
||||
def test_send_push_retries_mixed_expo_projects_per_device(self, mock_post):
|
||||
UserPushToken.objects.create(user=self.user, token="ExponentPushToken[a]", platform="ios")
|
||||
UserPushToken.objects.create(user=self.user, token="ExponentPushToken[b]", platform="ios")
|
||||
|
||||
class _MixedProjectResponse:
|
||||
status_code = 400
|
||||
|
||||
def json(self_inner):
|
||||
return {"errors": [{"code": "PUSH_TOO_MANY_EXPERIENCE_IDS"}]}
|
||||
|
||||
mock_post.side_effect = [
|
||||
_MixedProjectResponse(),
|
||||
self._stub_response(ok=["a"]),
|
||||
self._stub_response(ok=["b"]),
|
||||
]
|
||||
|
||||
accepted = send_push_to_user(self.user, title="posthog", body="task finished")
|
||||
|
||||
self.assertEqual(accepted, 2)
|
||||
self.assertEqual([len(call.kwargs["json"]) for call in mock_post.call_args_list], [2, 1, 1])
|
||||
self.assertEqual(
|
||||
{call.kwargs["json"][0]["to"] for call in mock_post.call_args_list[1:]},
|
||||
{"ExponentPushToken[a]", "ExponentPushToken[b]"},
|
||||
)
|
||||
|
||||
@patch("posthog.push_notifications.logger.warning")
|
||||
@patch("posthog.push_notifications.requests.post")
|
||||
def test_send_push_does_not_log_rejected_token(self, mock_post, mock_warning):
|
||||
UserPushToken.objects.create(user=self.user, token="ExponentPushToken[private]", platform="ios")
|
||||
|
||||
class _RejectedResponse:
|
||||
status_code = 400
|
||||
|
||||
def json(self_inner):
|
||||
return {"errors": [{"code": "INVALID_PUSH_TOKEN", "details": "ExponentPushToken[private]"}]}
|
||||
|
||||
mock_post.return_value = _RejectedResponse()
|
||||
|
||||
self.assertEqual(send_push_to_user(self.user, title="t", body="b"), 0)
|
||||
self.assertNotIn("ExponentPushToken[private]", str(mock_warning.call_args_list))
|
||||
|
||||
@patch("posthog.push_notifications.requests.post")
|
||||
def test_send_push_prunes_invalid_tokens(self, mock_post):
|
||||
UserPushToken.objects.create(user=self.user, token="ExponentPushToken[a]", platform="ios")
|
||||
|
||||
@@ -105,18 +105,37 @@ def _send_batch(
|
||||
)
|
||||
return 0
|
||||
if response.status_code >= 400:
|
||||
error_code = None
|
||||
try:
|
||||
error_body = response.json()
|
||||
errors = error_body.get("errors") if isinstance(error_body, dict) else None
|
||||
if isinstance(errors, list):
|
||||
error_code = next(
|
||||
(
|
||||
error.get("code")
|
||||
for error in errors
|
||||
if isinstance(error, dict) and isinstance(error.get("code"), str)
|
||||
),
|
||||
None,
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if error_code == "PUSH_TOO_MANY_EXPERIENCE_IDS" and len(tokens) > 1:
|
||||
return sum(_send_batch(user, [token], title=title, body=body, data=data) for token in tokens)
|
||||
|
||||
logger.warning(
|
||||
"expo_push.client_error",
|
||||
status_code=response.status_code,
|
||||
token_count=len(tokens),
|
||||
body=response.text[:500],
|
||||
error_code=error_code,
|
||||
)
|
||||
return 0
|
||||
|
||||
try:
|
||||
body_json = response.json()
|
||||
except ValueError:
|
||||
logger.warning("expo_push.invalid_response", body=response.text[:500])
|
||||
logger.warning("expo_push.invalid_response", status_code=response.status_code)
|
||||
return 0
|
||||
|
||||
tickets = body_json.get("data", []) if isinstance(body_json, dict) else []
|
||||
@@ -150,7 +169,6 @@ def _send_batch(
|
||||
logger.warning(
|
||||
"expo_push.ticket_error",
|
||||
error=error_code,
|
||||
message=ticket.get("message"),
|
||||
)
|
||||
|
||||
if invalid_tokens:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
class PostHogSceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
private var appDelegate: AppDelegate? {
|
||||
UIApplication.shared.delegate as? AppDelegate
|
||||
}
|
||||
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
guard let windowScene = scene as? UIWindowScene,
|
||||
let appDelegate,
|
||||
let factory = appDelegate.reactNativeFactory else { return }
|
||||
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
self.window = window
|
||||
appDelegate.window = window
|
||||
|
||||
var launchOptions = appDelegate.initialLaunchOptions ?? [:]
|
||||
if let context = connectionOptions.urlContexts.first {
|
||||
launchOptions[.url] = context.url
|
||||
launchOptions[.sourceApplication] = context.options.sourceApplication
|
||||
}
|
||||
if let activity = connectionOptions.userActivities.first {
|
||||
launchOptions[.userActivityDictionary] = [
|
||||
"UIApplicationLaunchOptionsUserActivityKey": activity,
|
||||
"UIApplicationLaunchOptionsUserActivityTypeKey": activity.activityType,
|
||||
]
|
||||
}
|
||||
|
||||
factory.startReactNative(withModuleName: "main", in: window, launchOptions: launchOptions)
|
||||
window.makeKeyAndVisible()
|
||||
self.scene(scene, openURLContexts: connectionOptions.urlContexts)
|
||||
for activity in connectionOptions.userActivities {
|
||||
self.scene(scene, continue: activity)
|
||||
}
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
for context in URLContexts {
|
||||
var options: [UIApplication.OpenURLOptionsKey: Any] = [
|
||||
.openInPlace: context.options.openInPlace,
|
||||
]
|
||||
options[.sourceApplication] = context.options.sourceApplication
|
||||
options[.annotation] = context.options.annotation
|
||||
_ = appDelegate?.application(UIApplication.shared, open: context.url, options: options)
|
||||
}
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
_ = appDelegate?.application(UIApplication.shared, continue: userActivity, restorationHandler: { _ in })
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
appDelegate?.applicationDidBecomeActive(UIApplication.shared)
|
||||
}
|
||||
|
||||
func sceneWillResignActive(_ scene: UIScene) {
|
||||
appDelegate?.applicationWillResignActive(UIApplication.shared)
|
||||
}
|
||||
|
||||
func sceneWillEnterForeground(_ scene: UIScene) {
|
||||
appDelegate?.applicationWillEnterForeground(UIApplication.shared)
|
||||
}
|
||||
|
||||
func sceneDidEnterBackground(_ scene: UIScene) {
|
||||
appDelegate?.applicationDidEnterBackground(UIApplication.shared)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const {
|
||||
withPodfileProperties,
|
||||
withXcodeProject,
|
||||
} = require("expo/config-plugins");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
module.exports = function withIosBuildCompatibility(config) {
|
||||
config = withPodfileProperties(config, (cfg) => {
|
||||
// ExpoVideo's precompiled binary references a symbol absent from ExpoModulesCore 57.0.7.
|
||||
cfg.modResults.EXPO_USE_PRECOMPILED_MODULES = "false";
|
||||
return cfg;
|
||||
});
|
||||
return withXcodeProject(config, (cfg) => {
|
||||
const name = cfg.modRequest.projectName;
|
||||
const schemes = path.join(
|
||||
cfg.modRequest.platformProjectRoot,
|
||||
`${name}.xcodeproj`,
|
||||
"xcshareddata/xcschemes",
|
||||
);
|
||||
// The PostHog SDK pod also exports a PostHog scheme; CLI builds can select the library.
|
||||
fs.copyFileSync(
|
||||
path.join(schemes, `${name}.xcscheme`),
|
||||
path.join(schemes, "PostHogMobile.xcscheme"),
|
||||
);
|
||||
return cfg;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
const { withAppDelegate, withInfoPlist } = require("expo/config-plugins");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const startup =
|
||||
/ {4}window = UIWindow\(frame: UIScreen\.main\.bounds\)\s+factory\.startReactNative\(\s+withModuleName: "main",\s+in: window,\s+launchOptions: launchOptions\)/;
|
||||
|
||||
module.exports = function withSceneLifecycle(config) {
|
||||
config = withInfoPlist(config, (cfg) => {
|
||||
cfg.modResults.UIApplicationSceneManifest = {
|
||||
UIApplicationSupportsMultipleScenes: false,
|
||||
UISceneConfigurations: {
|
||||
UIWindowSceneSessionRoleApplication: [
|
||||
{
|
||||
UISceneConfigurationName: "Default Configuration",
|
||||
UISceneDelegateClassName:
|
||||
"$(PRODUCT_MODULE_NAME).PostHogSceneDelegate",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
return cfg;
|
||||
});
|
||||
|
||||
return withAppDelegate(config, (cfg) => {
|
||||
if (cfg.modResults.language !== "swift") {
|
||||
throw new Error("withSceneLifecycle requires a Swift AppDelegate");
|
||||
}
|
||||
const contents = cfg.modResults.contents;
|
||||
if (contents.includes("class PostHogSceneDelegate:")) {
|
||||
return cfg;
|
||||
}
|
||||
if (!startup.test(contents)) {
|
||||
throw new Error(
|
||||
"Expo AppDelegate startup changed; review withSceneLifecycle before building",
|
||||
);
|
||||
}
|
||||
if (!contents.includes(" var window: UIWindow?")) {
|
||||
throw new Error(
|
||||
"Expo AppDelegate window declaration changed; review withSceneLifecycle before building",
|
||||
);
|
||||
}
|
||||
cfg.modResults.contents = `${contents
|
||||
.replace(
|
||||
" var window: UIWindow?",
|
||||
" var window: UIWindow?\n var initialLaunchOptions: [UIApplication.LaunchOptionsKey: Any]?",
|
||||
)
|
||||
.replace(startup, " initialLaunchOptions = launchOptions")}
|
||||
${fs.readFileSync(path.join(__dirname, "PostHogSceneDelegate.swift"), "utf8")}`;
|
||||
return cfg;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import withSceneLifecycle from "./withSceneLifecycle";
|
||||
|
||||
const appDelegate = `class AppDelegate: ExpoAppDelegate {
|
||||
var window: UIWindow?
|
||||
func application() {
|
||||
window = UIWindow(frame: UIScreen.main.bounds)
|
||||
factory.startReactNative(
|
||||
withModuleName: "main",
|
||||
in: window,
|
||||
launchOptions: launchOptions)
|
||||
}
|
||||
}`;
|
||||
|
||||
async function apply(contents, language = "swift") {
|
||||
const config = withSceneLifecycle({ name: "PostHog", slug: "posthog" });
|
||||
const result = await config.mods.ios.appDelegate({
|
||||
...config,
|
||||
modRequest: { platform: "ios", modName: "appDelegate" },
|
||||
modResults: { contents, language },
|
||||
});
|
||||
return result.modResults.contents;
|
||||
}
|
||||
|
||||
describe("withSceneLifecycle", () => {
|
||||
it("moves window creation into a single scene delegate across repeated prebuilds", async () => {
|
||||
const result = await apply(appDelegate);
|
||||
expect(result).not.toContain("UIWindow(frame: UIScreen.main.bounds)");
|
||||
expect(result).toContain("UIWindow(windowScene: windowScene)");
|
||||
expect(result).toContain("initialLaunchOptions = launchOptions");
|
||||
expect(result).toContain(
|
||||
"var launchOptions = appDelegate.initialLaunchOptions ?? [:]",
|
||||
);
|
||||
expect(await apply(result)).toBe(result);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[appDelegate.replace("factory.startReactNative", "factory.start"), "swift"],
|
||||
[appDelegate.replace(" var window: UIWindow?", ""), "swift"],
|
||||
[appDelegate, "objc"],
|
||||
])(
|
||||
"rejects unsupported native templates instead of generating a broken app",
|
||||
async (contents, language) => {
|
||||
await expect(apply(contents, language)).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios/*
|
||||
/android
|
||||
@@ -0,0 +1,28 @@
|
||||
# posthog
|
||||
|
||||
A stripped-back Expo app over PostHog cloud tasks, built for a local stack.
|
||||
|
||||
## Local stack
|
||||
|
||||
In the posthog repo, once:
|
||||
|
||||
```bash
|
||||
echo 'ALLOW_DEV_API_KEY_REVEAL=1' >> .env
|
||||
echo 'SANDBOX_PROVIDER=docker' >> .env
|
||||
flox activate -- bash -c "python manage.py setup_local_api_key"
|
||||
```
|
||||
|
||||
Then restart the stack. The login screen posts email and password to `/api/login/`, reads the seeded local dev personal API key, and uses it as a bearer token from then on.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd products/desktop
|
||||
pnpm install
|
||||
pnpm --filter @posthog/mobilehog ios # first build
|
||||
pnpm --filter @posthog/mobilehog start # afterwards
|
||||
```
|
||||
|
||||
The host lives in `src/config.ts`. Use your LAN IP instead of `localhost` on a real phone.
|
||||
|
||||
See [posthog release checks](../../../../docs/internal/mobilehog-release-checks.md) before you share a build.
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "PostHog",
|
||||
"slug": "mobilehog",
|
||||
"owner": "posthog",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"scheme": ["mobilehog", "posthog"],
|
||||
"icon": "./assets/app-icon.png",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.posthog.mobilehog",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsLocalNetworking": true,
|
||||
"NSAllowsArbitraryLoads": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#FFFFFF"
|
||||
},
|
||||
"package": "com.posthog.mobilehog"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Allow PostHog to attach photos to your tasks"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-speech-recognition",
|
||||
{
|
||||
"microphonePermission": "Allow PostHog to use the microphone for dictation.",
|
||||
"speechRecognitionPermission": "Allow PostHog to turn speech into a message draft."
|
||||
}
|
||||
],
|
||||
"./plugins/withSceneLifecycle",
|
||||
"./plugins/withPodfileResourceBundleFix",
|
||||
[
|
||||
"expo-font",
|
||||
{
|
||||
"ios": {
|
||||
"fonts": [
|
||||
"./assets/fonts/JetBrainsMono-Regular.ttf",
|
||||
"./assets/fonts/JetBrainsMono-Medium.ttf"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/logomark.png"
|
||||
}
|
||||
],
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/logomark.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"imageWidth": 160
|
||||
}
|
||||
],
|
||||
"expo-status-bar",
|
||||
"expo-web-browser",
|
||||
"expo-updates"
|
||||
],
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "d8e0731d-1107-4421-aa33-abf130eca27f"
|
||||
}
|
||||
},
|
||||
"runtimeVersion": {
|
||||
"policy": "fingerprint"
|
||||
},
|
||||
"updates": {
|
||||
"enabled": true,
|
||||
"checkAutomatically": "ON_LOAD",
|
||||
"fallbackToCacheTimeout": 0,
|
||||
"url": "https://u.expo.dev/d8e0731d-1107-4421-aa33-abf130eca27f"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 150 KiB |
@@ -0,0 +1,6 @@
|
||||
module.exports = (api) => {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"channel": "development"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"channel": "preview"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"channel": "production"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "expo-router/entry";
|
||||
@@ -0,0 +1,43 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const path = require("node:path");
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const monorepoRoot = path.resolve(projectRoot, "../..");
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
config.watchFolders = [monorepoRoot];
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, "node_modules"),
|
||||
path.resolve(monorepoRoot, "node_modules"),
|
||||
];
|
||||
config.resolver.extraNodeModules = {
|
||||
react: path.resolve(monorepoRoot, "node_modules/react"),
|
||||
};
|
||||
|
||||
// @posthog/shared's package exports point at dist/, which only exists after a
|
||||
// build. Resolve it to source so Babel transpiles it like the other packages.
|
||||
const SHARED_PACKAGE = "@posthog/shared";
|
||||
const sharedSrc = path.resolve(monorepoRoot, "packages/shared/src");
|
||||
const upstreamResolveRequest = config.resolver.resolveRequest;
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
if (
|
||||
moduleName === SHARED_PACKAGE ||
|
||||
moduleName.startsWith(`${SHARED_PACKAGE}/`)
|
||||
) {
|
||||
const subpath = moduleName.slice(SHARED_PACKAGE.length + 1) || "index";
|
||||
return context.resolveRequest(
|
||||
context,
|
||||
path.join(sharedSrc, subpath),
|
||||
platform,
|
||||
);
|
||||
}
|
||||
return (upstreamResolveRequest ?? context.resolveRequest)(
|
||||
context,
|
||||
moduleName,
|
||||
platform,
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "@posthog/mobilehog",
|
||||
"version": "0.1.0",
|
||||
"main": "./index.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"start:clear": "expo start --clear",
|
||||
"ios": "expo run:ios",
|
||||
"prebuild": "expo prebuild",
|
||||
"prebuild:clean": "expo prebuild --clean",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"clean": "node ../../scripts/rimraf.mjs .turbo .expo"
|
||||
},
|
||||
"expo": {
|
||||
"autolinking": {
|
||||
"buildFromSource": [
|
||||
"react-native-worklets"
|
||||
]
|
||||
},
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react",
|
||||
"react-dom",
|
||||
"typescript"
|
||||
]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~57.0.15",
|
||||
"@expo/ui": "~57.0.7",
|
||||
"@modelcontextprotocol/ext-apps": "^1.2.2",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@posthog/api-client": "workspace:*",
|
||||
"@posthog/brand": "catalog:",
|
||||
"@posthog/core": "workspace:*",
|
||||
"@posthog/shared": "workspace:*",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/query-sync-storage-persister": "5.101.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query-persist-client": "5.101.0",
|
||||
"expo": "57.0.23",
|
||||
"expo-auth-session": "~57.0.5",
|
||||
"expo-constants": "~57.0.18",
|
||||
"expo-crypto": "~57.0.1",
|
||||
"expo-dev-client": "~57.0.19",
|
||||
"expo-device": "~57.0.2",
|
||||
"expo-font": "~57.0.4",
|
||||
"expo-glass-effect": "~57.0.3",
|
||||
"expo-haptics": "~57.0.3",
|
||||
"expo-image-picker": "~57.0.6",
|
||||
"expo-linking": "~57.0.10",
|
||||
"expo-network": "~57.0.2",
|
||||
"expo-notifications": "~57.0.19",
|
||||
"expo-router": "~57.0.21",
|
||||
"expo-secure-store": "~57.0.4",
|
||||
"expo-speech-recognition": "56.0.1",
|
||||
"expo-splash-screen": "~57.0.9",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"expo-system-ui": "~57.0.4",
|
||||
"expo-updates": "~57.0.22",
|
||||
"expo-web-browser": "~57.0.2",
|
||||
"marked": "16.4.2",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-keyboard-controller": "1.21.9",
|
||||
"react-native-mmkv": "4.3.2",
|
||||
"react-native-nitro-modules": "0.37.1",
|
||||
"react-native-reanimated": "~4.5.0",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.2",
|
||||
"react-native-webview": "13.16.1",
|
||||
"react-native-worklets": "0.10.0",
|
||||
"zustand": "^4.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Xcode 14+ refuses to build resource-bundle targets that don't have a
|
||||
// development team set. Several common Pods (react-native-svg in
|
||||
// particular) ship bundles without a team and break EAS builds with:
|
||||
//
|
||||
// "Starting from Xcode 14, resource bundles are signed by default,
|
||||
// which requires setting the development team for each resource
|
||||
// bundle target."
|
||||
//
|
||||
// Disabling code signing for those bundle targets is the standard
|
||||
// workaround — they don't actually need to be signed because the host
|
||||
// app's signature covers them at runtime.
|
||||
//
|
||||
// Injecting this via a config plugin (instead of hand-editing Podfile)
|
||||
// means `expo prebuild --clean` won't wipe the fix.
|
||||
|
||||
const { withDangerousMod } = require("@expo/config-plugins");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const MARKER = "# CODE_SIGNING_ALLOWED resource-bundle fix";
|
||||
|
||||
const POST_INSTALL_SNIPPET = `
|
||||
${MARKER}
|
||||
installer.pods_project.targets.each do |target|
|
||||
if target.respond_to?(:product_type) && target.product_type == "com.apple.product-type.bundle"
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings["CODE_SIGNING_ALLOWED"] = "NO"
|
||||
end
|
||||
end
|
||||
end
|
||||
`;
|
||||
|
||||
const withPodfileResourceBundleFix = (config) =>
|
||||
withDangerousMod(config, [
|
||||
"ios",
|
||||
async (cfg) => {
|
||||
const podfilePath = path.join(
|
||||
cfg.modRequest.platformProjectRoot,
|
||||
"Podfile",
|
||||
);
|
||||
const contents = fs.readFileSync(podfilePath, "utf8");
|
||||
|
||||
if (contents.includes(MARKER)) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// Inject the snippet at the END of the existing post_install block.
|
||||
// The block opens with `post_install do |installer|` and closes at
|
||||
// the matching `end` — we find the closing `end` of that block by
|
||||
// tracking nesting depth.
|
||||
const startMatch = contents.match(/post_install do \|installer\|/);
|
||||
if (!startMatch) {
|
||||
throw new Error(
|
||||
"[withPodfileResourceBundleFix] could not find a post_install block to patch",
|
||||
);
|
||||
}
|
||||
const startIdx = startMatch.index + startMatch[0].length;
|
||||
const lines = contents.slice(startIdx).split("\n");
|
||||
let depth = 1;
|
||||
let endLineOffset = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
// crude but sufficient for the generated Podfile shape
|
||||
if (
|
||||
/\bdo\b/.test(line) ||
|
||||
/^(if|begin|case|class|def|module)\b/.test(line)
|
||||
) {
|
||||
depth += 1;
|
||||
}
|
||||
if (/^end\b/.test(line)) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
endLineOffset = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (endLineOffset === -1) {
|
||||
throw new Error(
|
||||
"[withPodfileResourceBundleFix] could not find the end of the post_install block",
|
||||
);
|
||||
}
|
||||
|
||||
// Insert our snippet just before that closing `end`.
|
||||
const insertAt =
|
||||
startIdx +
|
||||
lines.slice(0, endLineOffset).join("\n").length +
|
||||
(endLineOffset > 0 ? 1 : 0);
|
||||
const patched =
|
||||
contents.slice(0, insertAt) +
|
||||
POST_INSTALL_SNIPPET +
|
||||
contents.slice(insertAt);
|
||||
|
||||
fs.writeFileSync(podfilePath, patched, "utf8");
|
||||
return cfg;
|
||||
},
|
||||
]);
|
||||
|
||||
module.exports = withPodfileResourceBundleFix;
|
||||
@@ -0,0 +1,65 @@
|
||||
// Xcode 27's SDK refuses to launch apps that do not adopt the UIScene life
|
||||
// cycle. Expo 57.0.23 ships `ExpoAppSceneDelegate` but no plugin release yet,
|
||||
// so this mirrors @config-plugins/expo-uiscene-lifecycle: the AppDelegate
|
||||
// exposes its factory and the scene delegate creates the window.
|
||||
const { withDangerousMod, withInfoPlist } = require("@expo/config-plugins");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const CLASS_LINE = "class AppDelegate: ExpoAppDelegate {";
|
||||
const SCENE_CLASS_LINE =
|
||||
"class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {";
|
||||
|
||||
function patchAppDelegate(source) {
|
||||
if (source.includes(SCENE_CLASS_LINE)) return source;
|
||||
if (!source.includes(CLASS_LINE)) {
|
||||
throw new Error(
|
||||
"withSceneLifecycle: AppDelegate.swift is not the Expo 57 template",
|
||||
);
|
||||
}
|
||||
let out = source.replace(CLASS_LINE, SCENE_CLASS_LINE);
|
||||
out = out.replace(
|
||||
/\n\s*window = UIWindow\(frame: UIScreen\.main\.bounds\)\n/,
|
||||
"\n",
|
||||
);
|
||||
out = out.replace(
|
||||
/\n\s*factory\.startReactNative\(\s*withModuleName: "main",\s*in: window,\s*launchOptions: launchOptions\)\n/,
|
||||
"\n",
|
||||
);
|
||||
if (out.includes("startReactNative(")) {
|
||||
throw new Error(
|
||||
"withSceneLifecycle: could not remove the AppDelegate React Native start",
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = function withSceneLifecycle(config) {
|
||||
config = withInfoPlist(config, (mod) => {
|
||||
mod.modResults.UIApplicationSceneManifest = {
|
||||
UIApplicationSupportsMultipleScenes: false,
|
||||
UISceneConfigurations: {
|
||||
UIWindowSceneSessionRoleApplication: [
|
||||
{
|
||||
UISceneConfigurationName: "Default Configuration",
|
||||
UISceneDelegateClassName: "EXExpoAppSceneDelegate",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
return mod;
|
||||
});
|
||||
|
||||
return withDangerousMod(config, [
|
||||
"ios",
|
||||
(mod) => {
|
||||
const file = path.join(
|
||||
mod.modRequest.platformProjectRoot,
|
||||
mod.modRequest.projectName,
|
||||
"AppDelegate.swift",
|
||||
);
|
||||
fs.writeFileSync(file, patchAppDelegate(fs.readFileSync(file, "utf8")));
|
||||
return mod;
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Drawer } from "expo-router/drawer";
|
||||
import { DrawerContent } from "@/components/DrawerContent";
|
||||
import { colors, drawer } from "@/lib/theme";
|
||||
|
||||
export default function DrawerLayout() {
|
||||
return (
|
||||
<Drawer
|
||||
drawerContent={(props) => (
|
||||
<DrawerContent closeDrawer={() => props.navigation.closeDrawer()} />
|
||||
)}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
drawerType: "back",
|
||||
overlayColor: "transparent",
|
||||
drawerStyle: {
|
||||
width: `${drawer.widthFraction * 100}%`,
|
||||
backgroundColor: colors.bgDeep,
|
||||
},
|
||||
sceneStyle: { backgroundColor: "transparent" },
|
||||
swipeEdgeWidth: 60,
|
||||
}}
|
||||
>
|
||||
<Drawer.Screen name="index" />
|
||||
<Drawer.Screen name="task/[id]" />
|
||||
<Drawer.Screen name="activity" />
|
||||
<Drawer.Screen name="self-driving" />
|
||||
<Drawer.Screen name="recents" />
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { DrawerScene } from "@/components/DrawerScene";
|
||||
import { Glass, GlassCircleButton } from "@/components/Glass";
|
||||
import { MenuIcon } from "@/components/Icons";
|
||||
import {
|
||||
type ActivityRow,
|
||||
groupByDay,
|
||||
toRows,
|
||||
useActivity,
|
||||
useMarkActivityRead,
|
||||
} from "@/lib/activity";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
|
||||
function AgentGlyph({ icon }: { icon: NonNullable<ActivityRow["icon"]> }) {
|
||||
const glyph = icon === "check" ? "✓" : icon === "question" ? "?" : "…";
|
||||
return (
|
||||
<View style={[styles.glyph, icon === "check" && styles.glyphAccent]}>
|
||||
<Text
|
||||
style={[styles.glyphText, icon === "check" && styles.glyphTextAccent]}
|
||||
>
|
||||
{glyph}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActivityScreen() {
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation<{ openDrawer: () => void }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const email = useAuth((s) => s.session?.email ?? null);
|
||||
const name = useAuth((s) => s.session?.userName ?? null);
|
||||
const activity = useActivity();
|
||||
const markRead = useMarkActivityRead();
|
||||
const [unreadsOnly, setUnreadsOnly] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const rows = toRows(activity.data, email, name).filter(
|
||||
(row) =>
|
||||
(!unreadsOnly || row.item.isUnread) &&
|
||||
(!needle || row.item.taskTitle.toLowerCase().includes(needle)),
|
||||
);
|
||||
return groupByDay(rows);
|
||||
}, [activity.data, email, name, unreadsOnly, query]);
|
||||
|
||||
const open = (row: ActivityRow): void => {
|
||||
if (row.item.isUnread) markRead.mutate(row.item);
|
||||
router.push({
|
||||
pathname: "/(drawer)/task/[id]",
|
||||
params: { id: row.item.taskId },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DrawerScene>
|
||||
<View style={[styles.header, { paddingTop: insets.top + 6 }]}>
|
||||
<GlassCircleButton onPress={() => navigation.openDrawer()}>
|
||||
<MenuIcon />
|
||||
</GlassCircleButton>
|
||||
<Text style={styles.title}>Activity</Text>
|
||||
<View style={styles.unreads}>
|
||||
<Text style={styles.unreadsLabel}>Unreads</Text>
|
||||
<Switch
|
||||
value={unreadsOnly}
|
||||
onValueChange={setUnreadsOnly}
|
||||
trackColor={{ true: colors.accent }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scroll,
|
||||
{ paddingBottom: insets.bottom + 24 },
|
||||
]}
|
||||
keyboardDismissMode="on-drag"
|
||||
>
|
||||
<Glass style={styles.search}>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search activity"
|
||||
placeholderTextColor={colors.inkMute}
|
||||
style={styles.searchInput}
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</Glass>
|
||||
{groups.length === 0 ? (
|
||||
<Text style={styles.empty}>
|
||||
{activity.isLoading ? "Loading" : "Nothing here"}
|
||||
</Text>
|
||||
) : null}
|
||||
{groups.map((group) => (
|
||||
<View key={group.label} style={styles.group}>
|
||||
<Text style={styles.groupTitle}>{group.label}</Text>
|
||||
{group.rows.map((row) => (
|
||||
<Pressable
|
||||
key={row.item.id}
|
||||
onPress={() => open(row)}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
pressed && { opacity: 0.5 },
|
||||
]}
|
||||
>
|
||||
{row.icon ? (
|
||||
<AgentGlyph icon={row.icon} />
|
||||
) : (
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>{row.initials}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.body}>
|
||||
<Text
|
||||
style={[
|
||||
styles.rowTitle,
|
||||
row.item.isUnread && styles.rowTitleUnread,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{row.item.taskTitle}
|
||||
</Text>
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={styles.meta} numberOfLines={1}>
|
||||
{row.metadata}
|
||||
</Text>
|
||||
{row.space ? (
|
||||
<Text style={styles.spaceChip} numberOfLines={1}>
|
||||
{row.space}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{row.item.snippet ? (
|
||||
<Text style={styles.snippet} numberOfLines={2}>
|
||||
{row.item.snippet}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{row.item.isUnread ? <View style={styles.unreadDot} /> : null}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</DrawerScene>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.sansBold,
|
||||
fontSize: 26,
|
||||
color: colors.ink,
|
||||
},
|
||||
unreads: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
unreadsLabel: { fontFamily: fonts.sans, fontSize: 15, color: colors.inkSoft },
|
||||
scroll: { paddingHorizontal: 16, gap: 18, paddingTop: 6 },
|
||||
search: {
|
||||
borderRadius: radius.pill,
|
||||
paddingHorizontal: 16,
|
||||
overflow: "hidden",
|
||||
},
|
||||
searchInput: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 16,
|
||||
color: colors.ink,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
empty: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 14,
|
||||
color: colors.inkMute,
|
||||
paddingTop: 12,
|
||||
},
|
||||
group: { gap: 2 },
|
||||
groupTitle: {
|
||||
fontFamily: fonts.sansSemi,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1.2,
|
||||
textTransform: "uppercase",
|
||||
color: colors.inkMute,
|
||||
marginBottom: 6,
|
||||
marginLeft: 4,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
glyph: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.surface,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 2,
|
||||
},
|
||||
glyphAccent: { backgroundColor: colors.accent },
|
||||
glyphText: {
|
||||
fontFamily: fonts.sansBold,
|
||||
fontSize: 14,
|
||||
color: colors.inkSoft,
|
||||
},
|
||||
glyphTextAccent: { color: "#FFFFFF" },
|
||||
avatar: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgDeep,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 2,
|
||||
},
|
||||
avatarText: { fontFamily: fonts.sansBold, fontSize: 11, color: colors.ink },
|
||||
body: { flex: 1, gap: 2 },
|
||||
rowTitle: { fontFamily: fonts.sansMedium, fontSize: 16, color: colors.ink },
|
||||
rowTitleUnread: { fontFamily: fonts.sansSemi },
|
||||
metaRow: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
meta: {
|
||||
flexShrink: 1,
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 13,
|
||||
color: colors.inkMute,
|
||||
},
|
||||
spaceChip: {
|
||||
fontFamily: fonts.sansMedium,
|
||||
fontSize: 12,
|
||||
color: colors.inkSoft,
|
||||
backgroundColor: colors.bgDeep,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 5,
|
||||
overflow: "hidden",
|
||||
},
|
||||
snippet: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 14,
|
||||
color: colors.inkSoft,
|
||||
marginTop: 2,
|
||||
},
|
||||
unreadDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.accent,
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import {
|
||||
KeyboardStickyView,
|
||||
useReanimatedKeyboardAnimation,
|
||||
} from "react-native-keyboard-controller";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ChatHeader } from "@/components/ChatHeader";
|
||||
import { Composer } from "@/components/Composer";
|
||||
import { DrawerScene } from "@/components/DrawerScene";
|
||||
import { Logomark } from "@/components/Icons";
|
||||
import type { Photo } from "@/lib/attachments";
|
||||
import { sessionIdentity, useAuth } from "@/lib/auth";
|
||||
import {
|
||||
createAndRunTask,
|
||||
useDefaultRepository,
|
||||
useInvalidateTasks,
|
||||
} from "@/lib/queries";
|
||||
import { useSessions } from "@/lib/session";
|
||||
import { colors, fonts } from "@/lib/theme";
|
||||
|
||||
export default function NewChatScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const userName = useAuth((s) => s.session?.userName ?? "");
|
||||
const repository = useDefaultRepository();
|
||||
const invalidateTasks = useInvalidateTasks();
|
||||
// Keep the greeting centred in the space the keyboard leaves. The reported
|
||||
// height covers the bottom inset too, which the composer already occupied.
|
||||
const keyboard = useReanimatedKeyboardAnimation();
|
||||
const bottomInset = insets.bottom;
|
||||
const hero = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateY: Math.min(0, keyboard.height.value + bottomInset) / 2,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// Open the chat immediately with the message in it; the task and its run
|
||||
// are created behind that screen, then the chat is re-keyed to the real id.
|
||||
const send = async (text: string, photos: Photo[]): Promise<void> => {
|
||||
const identity = sessionIdentity();
|
||||
const tempId = `new-${Date.now()}`;
|
||||
const { startPending, adopt, failPending } = useSessions.getState();
|
||||
startPending(
|
||||
tempId,
|
||||
text,
|
||||
`local-${Date.now()}`,
|
||||
photos.length ? photos.map((photo) => photo.uri) : undefined,
|
||||
);
|
||||
router.replace({ pathname: "/(drawer)/task/[id]", params: { id: tempId } });
|
||||
try {
|
||||
const task = await createAndRunTask({
|
||||
prompt: text,
|
||||
repository: repository.data ?? null,
|
||||
photos,
|
||||
});
|
||||
if (sessionIdentity() !== identity) return;
|
||||
adopt(tempId, task);
|
||||
invalidateTasks();
|
||||
router.replace({
|
||||
pathname: "/(drawer)/task/[id]",
|
||||
params: { id: task.id },
|
||||
});
|
||||
} catch (err) {
|
||||
if (sessionIdentity() !== identity) return;
|
||||
failPending(tempId, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DrawerScene>
|
||||
<ChatHeader showNewChat={false} />
|
||||
<Animated.View style={[styles.center, hero]}>
|
||||
<Logomark />
|
||||
<Text style={styles.greeting}>
|
||||
{userName ? `${userName} returns!` : "G'day"}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
<KeyboardStickyView offset={{ closed: 0, opened: insets.bottom }}>
|
||||
<View style={[styles.composer, { paddingBottom: insets.bottom + 8 }]}>
|
||||
<Composer
|
||||
placeholder="Chat with PostHog"
|
||||
repository={repository.data ?? null}
|
||||
onSend={send}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
</KeyboardStickyView>
|
||||
</DrawerScene>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
greeting: {
|
||||
fontFamily: fonts.serif,
|
||||
fontSize: 30,
|
||||
color: colors.ink,
|
||||
textAlign: "center",
|
||||
},
|
||||
composer: { paddingHorizontal: 12 },
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import { Button, Host, Image, Menu } from "@expo/ui/swift-ui";
|
||||
import { frame, glassEffect } from "@expo/ui/swift-ui/modifiers";
|
||||
import type { SignalReport, Task } from "@posthog/shared/domain-types";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { KeyboardStickyView } from "react-native-keyboard-controller";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeInDown,
|
||||
FadeOut,
|
||||
FadeOutDown,
|
||||
LinearTransition,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { DrawerScene } from "@/components/DrawerScene";
|
||||
import { FadeScrim } from "@/components/FadeScrim";
|
||||
import { Glass, GlassCircleButton } from "@/components/Glass";
|
||||
import { MenuIcon } from "@/components/Icons";
|
||||
import {
|
||||
activityAt,
|
||||
ListRow,
|
||||
RowSkeletons,
|
||||
TaskRow,
|
||||
} from "@/components/TaskRow";
|
||||
import { useTaskPages } from "@/lib/queries";
|
||||
import { useReports } from "@/lib/reports";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
|
||||
type Scope = "all" | "tasks" | "reports" | "archived";
|
||||
|
||||
type Item =
|
||||
| { kind: "task"; id: string; time: string; task: Task }
|
||||
| { kind: "report"; id: string; time: string; report: SignalReport };
|
||||
|
||||
const SCOPES = [
|
||||
{ value: "all", label: "All", icon: "bubble.left.and.bubble.right" },
|
||||
{ value: "tasks", label: "Tasks", icon: "bubble.left" },
|
||||
{ value: "reports", label: "Self-driving", icon: "steeringwheel" },
|
||||
{ value: "archived", label: "Archived", icon: "archivebox" },
|
||||
] as const;
|
||||
|
||||
const HEADER_HEIGHT = 64;
|
||||
|
||||
function useDebounced(value: string, delay: number): string {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export default function RecentsScreen() {
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation<{ openDrawer: () => void }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const input = useRef<TextInput>(null);
|
||||
const [searching, setSearching] = useState(true);
|
||||
const [query, setQuery] = useState("");
|
||||
const [scope, setScope] = useState<Scope>("all");
|
||||
const search = useDebounced(query.trim(), 250);
|
||||
const archived = scope === "archived";
|
||||
const showReports = scope === "all" || scope === "reports";
|
||||
const tasks = useTaskPages(search, archived);
|
||||
const reports = useReports(search);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const list: Item[] = [];
|
||||
if (scope !== "reports") {
|
||||
for (const task of tasks.data?.pages.flatMap((page) => page.visible) ??
|
||||
[]) {
|
||||
list.push({ kind: "task", id: task.id, time: activityAt(task), task });
|
||||
}
|
||||
}
|
||||
if (showReports) {
|
||||
for (const report of reports.data ?? []) {
|
||||
list.push({
|
||||
kind: "report",
|
||||
id: report.id,
|
||||
time: report.updated_at,
|
||||
report,
|
||||
});
|
||||
}
|
||||
}
|
||||
return list.sort((a, b) => b.time.localeCompare(a.time));
|
||||
}, [scope, showReports, tasks.data, reports.data]);
|
||||
|
||||
const loading =
|
||||
(scope !== "reports" && tasks.isLoading) ||
|
||||
(showReports && reports.isLoading);
|
||||
|
||||
const closeSearch = (): void => {
|
||||
setQuery("");
|
||||
input.current?.blur();
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const top = insets.top + HEADER_HEIGHT;
|
||||
|
||||
return (
|
||||
<DrawerScene>
|
||||
<FlashList
|
||||
data={items}
|
||||
keyExtractor={(item) => `${item.kind}-${item.id}`}
|
||||
getItemType={(item) => item.kind}
|
||||
renderItem={({ item }) =>
|
||||
item.kind === "task" ? (
|
||||
<TaskRow
|
||||
task={item.task}
|
||||
label="Task"
|
||||
archived={archived}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/(drawer)/task/[id]",
|
||||
params: { id: item.id },
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ListRow
|
||||
title={item.report.title ?? "Untitled report"}
|
||||
label="Report"
|
||||
time={item.time}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/(drawer)/self-driving",
|
||||
params: { report: item.id },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
contentContainerStyle={{
|
||||
paddingTop: top,
|
||||
paddingBottom: insets.bottom + 140,
|
||||
paddingHorizontal: 18,
|
||||
}}
|
||||
onEndReached={() => {
|
||||
if (scope !== "reports" && tasks.hasNextPage && !tasks.isFetching) {
|
||||
void tasks.fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={
|
||||
tasks.isFetchingNextPage ? <RowSkeletons count={2} /> : null
|
||||
}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
/>
|
||||
{loading && items.length === 0 ? (
|
||||
<View style={[styles.overlay, { top }]} pointerEvents="none">
|
||||
<RowSkeletons />
|
||||
</View>
|
||||
) : null}
|
||||
{!loading && items.length === 0 ? (
|
||||
<Text style={[styles.empty, { top: top + 12 }]}>
|
||||
{search ? `Nothing matches “${search}”` : "Nothing here yet"}
|
||||
</Text>
|
||||
) : null}
|
||||
<FadeScrim
|
||||
style={[styles.topScrim, { height: top + 8 }]}
|
||||
color={colors.bg}
|
||||
/>
|
||||
{searching ? null : (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(220)}
|
||||
exiting={FadeOut.duration(160)}
|
||||
style={[styles.header, { paddingTop: insets.top + 6 }]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<GlassCircleButton onPress={() => navigation.openDrawer()}>
|
||||
<MenuIcon />
|
||||
</GlassCircleButton>
|
||||
<Text style={styles.title}>Search</Text>
|
||||
<Host matchContents>
|
||||
<Menu
|
||||
label={
|
||||
<Image
|
||||
systemName="slider.horizontal.3"
|
||||
size={18}
|
||||
color={colors.ink}
|
||||
/>
|
||||
}
|
||||
modifiers={[
|
||||
frame({ width: 46, height: 46 }),
|
||||
glassEffect({
|
||||
glass: { variant: "regular", interactive: true },
|
||||
shape: "circle",
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{SCOPES.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
systemImage={
|
||||
scope === option.value ? "checkmark" : option.icon
|
||||
}
|
||||
onPress={() => setScope(option.value)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</Host>
|
||||
</Animated.View>
|
||||
)}
|
||||
<KeyboardStickyView
|
||||
style={styles.dock}
|
||||
offset={{ closed: 0, opened: insets.bottom }}
|
||||
>
|
||||
<FadeScrim style={styles.bottomScrim} color={colors.bg} />
|
||||
<View style={{ paddingBottom: insets.bottom + 8 }}>
|
||||
{searching ? null : (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(240)}
|
||||
exiting={FadeOutDown.duration(160)}
|
||||
style={styles.newTaskWrap}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => router.replace("/(drawer)")}
|
||||
style={({ pressed }) => [
|
||||
styles.newTask,
|
||||
pressed && { opacity: 0.8 },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.newTaskPlus}>+</Text>
|
||||
<Text style={styles.newTaskText}>New task</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
)}
|
||||
<View style={styles.searchRow}>
|
||||
<Animated.View
|
||||
layout={LinearTransition.duration(220)}
|
||||
style={styles.searchFlex}
|
||||
>
|
||||
<Glass interactive style={styles.searchBar}>
|
||||
<Host matchContents>
|
||||
<Image
|
||||
systemName="magnifyingglass"
|
||||
size={17}
|
||||
color={colors.inkSoft}
|
||||
/>
|
||||
</Host>
|
||||
<TextInput
|
||||
ref={input}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
onFocus={() => setSearching(true)}
|
||||
placeholder="Search"
|
||||
placeholderTextColor={colors.inkMute}
|
||||
autoFocus
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
style={styles.searchInput}
|
||||
/>
|
||||
</Glass>
|
||||
</Animated.View>
|
||||
{searching ? (
|
||||
<Animated.View
|
||||
entering={ZoomIn.duration(200)}
|
||||
exiting={ZoomOut.duration(160)}
|
||||
>
|
||||
<GlassCircleButton size={50} onPress={closeSearch}>
|
||||
<Host matchContents>
|
||||
<Image systemName="xmark" size={18} color={colors.ink} />
|
||||
</Host>
|
||||
</GlassCircleButton>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardStickyView>
|
||||
</DrawerScene>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.sansBold,
|
||||
fontSize: 26,
|
||||
color: colors.ink,
|
||||
},
|
||||
topScrim: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
transform: [{ rotate: "180deg" }],
|
||||
},
|
||||
overlay: { position: "absolute", left: 18, right: 18 },
|
||||
empty: {
|
||||
position: "absolute",
|
||||
left: 22,
|
||||
right: 22,
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 15,
|
||||
color: colors.inkMute,
|
||||
},
|
||||
dock: { position: "absolute", left: 0, right: 0, bottom: 0 },
|
||||
bottomScrim: { position: "absolute", left: 0, right: 0, top: -24, bottom: 0 },
|
||||
searchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
searchFlex: { flex: 1 },
|
||||
searchBar: {
|
||||
height: 50,
|
||||
borderRadius: radius.pill,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
paddingHorizontal: 18,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
height: "100%",
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 17,
|
||||
color: colors.ink,
|
||||
},
|
||||
newTaskWrap: { alignSelf: "flex-end", marginRight: 16, marginBottom: 12 },
|
||||
newTask: {
|
||||
height: 52,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
backgroundColor: colors.dark,
|
||||
paddingLeft: 18,
|
||||
paddingRight: 24,
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
newTaskPlus: {
|
||||
color: colors.darkText,
|
||||
fontSize: 26,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.sansMedium,
|
||||
marginTop: -2,
|
||||
},
|
||||
newTaskText: {
|
||||
color: colors.darkText,
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.sansSemi,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import { buildCreatePrReportPrompt } from "@posthog/core/inbox/reportActions";
|
||||
import { formatRelativeAge } from "@posthog/shared";
|
||||
import type { SignalReport } from "@posthog/shared/domain-types";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeInDown,
|
||||
FadeOut,
|
||||
FadeOutDown,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { DrawerScene } from "@/components/DrawerScene";
|
||||
import { FadeScrim } from "@/components/FadeScrim";
|
||||
import { GlassCircleButton } from "@/components/Glass";
|
||||
import { MenuIcon } from "@/components/Icons";
|
||||
import { PriorityChip } from "@/components/ReportCard";
|
||||
import { TriageDeck } from "@/components/TriageDeck";
|
||||
import {
|
||||
useDismissReport,
|
||||
useReports,
|
||||
useSeenReports,
|
||||
useStartReport,
|
||||
} from "@/lib/reports";
|
||||
import { useSessions } from "@/lib/session";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
|
||||
export default function SelfDrivingScreen() {
|
||||
const navigation = useNavigation<{ openDrawer: () => void }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const reports = useReports();
|
||||
const seen = useSeenReports((s) => s.seen);
|
||||
const seenHydrated = useSeenReports((s) => s.hydrated);
|
||||
const markSeen = useSeenReports((s) => s.markSeen);
|
||||
const dismiss = useDismissReport();
|
||||
const start = useStartReport();
|
||||
// Locally swiped ids, so a card leaves the deck before the server catches up.
|
||||
const [handled, setHandled] = useState<Set<string>>(new Set());
|
||||
const [deck, setDeck] = useState<string[] | null>(null);
|
||||
const { report: linkedReport } = useLocalSearchParams<{ report?: string }>();
|
||||
useEffect(() => {
|
||||
if (linkedReport) setDeck([linkedReport]);
|
||||
}, [linkedReport]);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timer = setTimeout(() => setNotice(null), 6000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [notice]);
|
||||
|
||||
const all = useMemo(
|
||||
() => (reports.data ?? []).filter((report) => !handled.has(report.id)),
|
||||
[reports.data, handled],
|
||||
);
|
||||
const unseen = useMemo(
|
||||
() => all.filter((report) => !seen.has(report.id)),
|
||||
[all, seen],
|
||||
);
|
||||
|
||||
// New reports since the last visit open the deck on their own.
|
||||
useEffect(() => {
|
||||
if (deck === null && seenHydrated && reports.data && unseen.length > 0) {
|
||||
setDeck(unseen.map((report) => report.id));
|
||||
}
|
||||
}, [deck, seenHydrated, reports.data, unseen]);
|
||||
|
||||
const deckReports = useMemo(
|
||||
() =>
|
||||
(deck ?? [])
|
||||
.map((id) => all.find((report) => report.id === id))
|
||||
.filter((report): report is SignalReport => !!report),
|
||||
[deck, all],
|
||||
);
|
||||
|
||||
// Whatever surfaces at the top of the deck counts as seen.
|
||||
const topId = deckReports[0]?.id;
|
||||
useEffect(() => {
|
||||
if (topId && !seen.has(topId)) markSeen([topId]);
|
||||
}, [topId, seen, markSeen]);
|
||||
|
||||
const finish = (report: SignalReport): void => {
|
||||
setHandled((current) => new Set(current).add(report.id));
|
||||
markSeen([report.id]);
|
||||
};
|
||||
|
||||
// A failed action leaves the report open on the server, so put the card back
|
||||
// in the deck for another try.
|
||||
const restore = (report: SignalReport): void => {
|
||||
setHandled((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(report.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onDismiss = (report: SignalReport): void => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Rigid).catch(() => {});
|
||||
finish(report);
|
||||
dismiss.mutate(report.id, {
|
||||
onError: (error) => {
|
||||
restore(report);
|
||||
setNotice(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Open the chat immediately with the report prompt in it (the same pending
|
||||
// pattern as the new-chat screen), then re-key it to the real task id.
|
||||
const onStart = (report: SignalReport): void => {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(
|
||||
() => {},
|
||||
);
|
||||
finish(report);
|
||||
const tempId = `new-${Date.now()}`;
|
||||
const prompt = buildCreatePrReportPrompt({ reportId: report.id });
|
||||
const { startPending, adopt, failPending } = useSessions.getState();
|
||||
startPending(tempId, prompt, `local-${Date.now()}`);
|
||||
router.push({ pathname: "/(drawer)/task/[id]", params: { id: tempId } });
|
||||
start.mutate(report, {
|
||||
onSuccess: (task) => {
|
||||
adopt(tempId, task);
|
||||
router.replace({
|
||||
pathname: "/(drawer)/task/[id]",
|
||||
params: { id: task.id },
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
restore(report);
|
||||
failPending(tempId, error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const showDeck = deck !== null && deckReports.length > 0;
|
||||
const headerHeight = insets.top + 58;
|
||||
|
||||
return (
|
||||
<DrawerScene>
|
||||
<View style={[styles.header, { paddingTop: insets.top + 6 }]}>
|
||||
{showDeck ? (
|
||||
<GlassCircleButton onPress={() => setDeck([])}>
|
||||
<Text style={styles.headerGlyph}>×</Text>
|
||||
</GlassCircleButton>
|
||||
) : (
|
||||
<GlassCircleButton onPress={() => navigation.openDrawer()}>
|
||||
<MenuIcon />
|
||||
</GlassCircleButton>
|
||||
)}
|
||||
<Text style={styles.title}>{showDeck ? "Triage" : "Reports"}</Text>
|
||||
<View style={{ width: 46 }} />
|
||||
</View>
|
||||
|
||||
{showDeck ? (
|
||||
<Animated.View
|
||||
key="deck"
|
||||
exiting={FadeOutDown.duration(200)}
|
||||
style={StyleSheet.absoluteFill}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<TriageDeck
|
||||
reports={deckReports}
|
||||
onDismiss={onDismiss}
|
||||
onStart={onStart}
|
||||
starting={start.isPending}
|
||||
headerHeight={headerHeight}
|
||||
/>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.ScrollView
|
||||
key="list"
|
||||
entering={FadeIn.duration(240)}
|
||||
exiting={FadeOut.duration(160)}
|
||||
contentContainerStyle={[
|
||||
styles.list,
|
||||
{ paddingBottom: insets.bottom + 100 },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{all.length === 0 && !reports.isLoading ? "All clear" : "Reports"}
|
||||
</Text>
|
||||
{reports.isLoading ? <Text style={styles.muted}>Loading</Text> : null}
|
||||
{all.map((report) => (
|
||||
<Pressable
|
||||
key={report.id}
|
||||
onPress={() => setDeck([report.id])}
|
||||
style={({ pressed }) => [styles.row, pressed && { opacity: 0.5 }]}
|
||||
>
|
||||
{report.priority ? (
|
||||
<PriorityChip priority={report.priority} />
|
||||
) : null}
|
||||
<View style={styles.rowBody}>
|
||||
<Text style={styles.rowTitle} numberOfLines={2}>
|
||||
{report.title ?? "Untitled report"}
|
||||
</Text>
|
||||
<Text style={styles.rowMeta}>
|
||||
{report.signal_count} signal
|
||||
{report.signal_count === 1 ? "" : "s"} ·{" "}
|
||||
{formatRelativeAge(report.updated_at)}
|
||||
</Text>
|
||||
</View>
|
||||
{!seen.has(report.id) ? <View style={styles.newDot} /> : null}
|
||||
</Pressable>
|
||||
))}
|
||||
</Animated.ScrollView>
|
||||
)}
|
||||
{notice ? (
|
||||
<Animated.View
|
||||
key={notice}
|
||||
entering={FadeInDown.duration(220)}
|
||||
exiting={FadeOutDown.duration(180)}
|
||||
style={[styles.toast, { bottom: insets.bottom + 124 }]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Text style={styles.notice}>{notice}</Text>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
{!showDeck && all.length > 0 ? (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(260)}
|
||||
exiting={FadeOutDown.duration(180)}
|
||||
style={[styles.floating, { paddingBottom: insets.bottom + 14 }]}
|
||||
>
|
||||
<FadeScrim style={styles.floatingScrim} />
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
setDeck((unseen.length > 0 ? unseen : all).map((r) => r.id))
|
||||
}
|
||||
style={({ pressed }) => [
|
||||
styles.triage,
|
||||
pressed && { opacity: 0.8 },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.triageText}>
|
||||
{unseen.length > 0
|
||||
? `Triage ${unseen.length} new report${unseen.length === 1 ? "" : "s"}`
|
||||
: `Triage ${all.length} report${all.length === 1 ? "" : "s"}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</DrawerScene>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 6,
|
||||
},
|
||||
title: { fontFamily: fonts.sansBold, fontSize: 22, color: colors.ink },
|
||||
headerGlyph: {
|
||||
fontSize: 26,
|
||||
lineHeight: 28,
|
||||
color: colors.ink,
|
||||
marginTop: -2,
|
||||
},
|
||||
list: { paddingHorizontal: 18, paddingTop: 8, gap: 10 },
|
||||
toast: { position: "absolute", left: 18, right: 18, alignItems: "center" },
|
||||
notice: {
|
||||
fontFamily: fonts.sansMedium,
|
||||
fontSize: 14,
|
||||
color: colors.darkText,
|
||||
backgroundColor: colors.dark,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 11,
|
||||
borderRadius: radius.pill,
|
||||
overflow: "hidden",
|
||||
},
|
||||
floating: {
|
||||
position: "absolute",
|
||||
left: 18,
|
||||
right: 18,
|
||||
bottom: 0,
|
||||
},
|
||||
floatingScrim: {
|
||||
position: "absolute",
|
||||
left: -18,
|
||||
right: -18,
|
||||
top: -48,
|
||||
bottom: 0,
|
||||
},
|
||||
triage: {
|
||||
backgroundColor: colors.dark,
|
||||
borderRadius: radius.pill,
|
||||
paddingVertical: 16,
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
},
|
||||
triageText: {
|
||||
fontFamily: fonts.sansSemi,
|
||||
fontSize: 16,
|
||||
color: colors.darkText,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontFamily: fonts.sansSemi,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1.2,
|
||||
textTransform: "uppercase",
|
||||
color: colors.inkMute,
|
||||
marginTop: 8,
|
||||
marginLeft: 4,
|
||||
},
|
||||
muted: { fontFamily: fonts.sans, fontSize: 14, color: colors.inkMute },
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 18,
|
||||
padding: 14,
|
||||
},
|
||||
rowBody: { flex: 1, gap: 3 },
|
||||
rowTitle: {
|
||||
fontFamily: fonts.sansMedium,
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
color: colors.ink,
|
||||
},
|
||||
rowMeta: { fontFamily: fonts.sans, fontSize: 12, color: colors.inkMute },
|
||||
newDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.accent,
|
||||
marginTop: 6,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { FlashList, type FlashListRef } from "@shopify/flash-list";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { KeyboardStickyView } from "react-native-keyboard-controller";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ChatHeader } from "@/components/ChatHeader";
|
||||
import { Composer } from "@/components/Composer";
|
||||
import { DrawerScene } from "@/components/DrawerScene";
|
||||
import { FadeScrim } from "@/components/FadeScrim";
|
||||
import { Hedgehog } from "@/components/Hedgehog";
|
||||
import {
|
||||
buildTranscriptRows,
|
||||
StatusLine,
|
||||
type TranscriptRow,
|
||||
TranscriptRowView,
|
||||
} from "@/components/Transcript";
|
||||
import type { Photo } from "@/lib/attachments";
|
||||
import { useComposer } from "@/lib/composer";
|
||||
import { usePrefs } from "@/lib/prefs";
|
||||
import { useTask } from "@/lib/queries";
|
||||
import { useSessions } from "@/lib/session";
|
||||
import { colors, fonts } from "@/lib/theme";
|
||||
|
||||
export default function TaskScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
// Placeholder chats have no task yet; the store holds their session.
|
||||
const isPending = id.startsWith("new-");
|
||||
const task = useTask(isPending ? "" : id);
|
||||
const session = useSessions((s) => s.sessions[id]);
|
||||
const hedgehogMode = usePrefs((s) => s.hedgehogMode);
|
||||
const { connect, disconnect, sendPrompt, cancelTurn, respondToPermission } =
|
||||
useSessions();
|
||||
const setModel = useComposer((s) => s.setModel);
|
||||
const listRef = useRef<FlashListRef<TranscriptRow>>(null);
|
||||
const pendingScrollTo = useRef<string | null>(null);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [composerHeight, setComposerHeight] = useState(0);
|
||||
// Extra room below the transcript only while a sent message is pinned near
|
||||
// the top; it goes away once the reply lands so idle chats have no gap.
|
||||
const [pinRoom, setPinRoom] = useState(false);
|
||||
const topPadding = insets.top + 70;
|
||||
|
||||
const runId = task.data?.latest_run?.id;
|
||||
useEffect(() => {
|
||||
if (task.data && runId) connect(task.data);
|
||||
}, [task.data, runId, connect]);
|
||||
|
||||
useEffect(() => () => disconnect(id), [id, disconnect]);
|
||||
|
||||
useEffect(() => {
|
||||
const runModel = task.data?.latest_run?.model;
|
||||
if (runModel) setModel(runModel);
|
||||
}, [task.data?.latest_run?.model, setModel]);
|
||||
|
||||
const blocks = session?.blocks;
|
||||
// Walks while thinking or using tools; stands still once text is streaming.
|
||||
const tail = blocks?.[blocks.length - 1];
|
||||
const streamingText = tail?.kind === "agent" && !tail.complete;
|
||||
const walking = !!session?.turnActive && !streamingText;
|
||||
const booting =
|
||||
!!session &&
|
||||
(session.runStatus === "queued" || session.runStatus === "not_started") &&
|
||||
!blocks?.some((block) => block.kind !== "user");
|
||||
|
||||
const turnActive = !!session?.turnActive;
|
||||
useEffect(() => {
|
||||
if (!turnActive) setPinRoom(false);
|
||||
}, [turnActive]);
|
||||
|
||||
const workingLabel = session?.resuming
|
||||
? "Reconnecting"
|
||||
: booting
|
||||
? "Starting sandbox"
|
||||
: session?.connected
|
||||
? "Working"
|
||||
: "Connecting";
|
||||
const rows = useMemo(
|
||||
() => (session ? buildTranscriptRows(session, workingLabel) : []),
|
||||
[session, workingLabel],
|
||||
);
|
||||
|
||||
const send = async (text: string, photos: Photo[]): Promise<void> => {
|
||||
// The bubble lays out before the request resolves, so pick the id first.
|
||||
const blockId = `local-${Date.now()}`;
|
||||
pendingScrollTo.current = blockId;
|
||||
setPinRoom(true);
|
||||
try {
|
||||
await sendPrompt(id, text, blockId, photos);
|
||||
} catch (error) {
|
||||
if (pendingScrollTo.current === blockId) pendingScrollTo.current = null;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Once the sent bubble is a row, pin it near the top with a sliver of the
|
||||
// previous reply above it.
|
||||
useEffect(() => {
|
||||
const target = pendingScrollTo.current;
|
||||
if (!target) return;
|
||||
const index = rows.findIndex((row) => row.id === target);
|
||||
if (index < 0) return;
|
||||
pendingScrollTo.current = null;
|
||||
listRef.current?.scrollToIndex({
|
||||
index,
|
||||
viewPosition: 0,
|
||||
viewOffset: 56,
|
||||
animated: true,
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
return (
|
||||
<DrawerScene>
|
||||
<ChatHeader task={task.data} />
|
||||
<FlashList
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
keyExtractor={(row) => row.id}
|
||||
getItemType={(row) => row.kind}
|
||||
renderItem={({ item }) => (
|
||||
<TranscriptRowView
|
||||
row={item}
|
||||
onPermission={(toolCallId, optionId) =>
|
||||
respondToPermission(id, toolCallId, optionId)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
ItemSeparatorComponent={Gap}
|
||||
onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)}
|
||||
contentContainerStyle={{
|
||||
paddingTop: topPadding,
|
||||
paddingHorizontal: 18,
|
||||
// Clear the floating composer, and leave room to pin a fresh
|
||||
// message near the top of the screen.
|
||||
paddingBottom: pinRoom
|
||||
? Math.max(composerHeight + 16, viewportHeight - 200)
|
||||
: composerHeight + 16,
|
||||
}}
|
||||
// Open at the end, and keep following it while the reader is near the
|
||||
// bottom; a pinned message sits far enough up to opt out on its own.
|
||||
maintainVisibleContentPosition={{
|
||||
startRenderingFromBottom: true,
|
||||
autoscrollToBottomThreshold: 0.2,
|
||||
animateAutoScrollToBottom: false,
|
||||
}}
|
||||
keyboardDismissMode="interactive"
|
||||
ListEmptyComponent={
|
||||
<View style={styles.loading}>
|
||||
<StatusLine
|
||||
label={task.error ? task.error.message : "Loading"}
|
||||
active={!task.error}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
session?.error ? (
|
||||
<Text style={styles.error}>{session.error}</Text>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<KeyboardStickyView
|
||||
offset={{ closed: 0, opened: insets.bottom }}
|
||||
style={styles.dock}
|
||||
>
|
||||
<View
|
||||
style={[styles.composer, { paddingBottom: insets.bottom + 8 }]}
|
||||
onLayout={(event) =>
|
||||
setComposerHeight(event.nativeEvent.layout.height)
|
||||
}
|
||||
>
|
||||
{session && hedgehogMode ? <Hedgehog walking={walking} /> : null}
|
||||
<View>
|
||||
<FadeScrim style={styles.scrim} />
|
||||
<Composer
|
||||
placeholder="Reply"
|
||||
onSend={send}
|
||||
onStop={isPending ? undefined : () => cancelTurn(id)}
|
||||
busy={session?.turnActive}
|
||||
sending={isPending}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardStickyView>
|
||||
</DrawerScene>
|
||||
);
|
||||
}
|
||||
|
||||
function Gap() {
|
||||
return <View style={styles.gap} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
loading: { paddingTop: 4 },
|
||||
gap: { height: 14 },
|
||||
error: {
|
||||
color: colors.danger,
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 13,
|
||||
paddingHorizontal: 18,
|
||||
marginTop: 8,
|
||||
},
|
||||
dock: { position: "absolute", left: 0, right: 0, bottom: 0 },
|
||||
scrim: {
|
||||
position: "absolute",
|
||||
top: -40,
|
||||
bottom: -60,
|
||||
left: -12,
|
||||
right: -12,
|
||||
},
|
||||
composer: { paddingHorizontal: 12 },
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useFonts } from "expo-font";
|
||||
import { Stack, useRouter, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { OfflinePill } from "@/components/OfflinePill";
|
||||
import { getAccountQueryClient } from "@/lib/accountLifecycle";
|
||||
import { sessionIdentity, useAuth } from "@/lib/auth";
|
||||
import "@/lib/network";
|
||||
import { usePushNotifications } from "@/lib/notifications";
|
||||
import { usePrefs } from "@/lib/prefs";
|
||||
import { useRepo } from "@/lib/repo";
|
||||
import { useSeenReports } from "@/lib/reports";
|
||||
import { useSessions } from "@/lib/session";
|
||||
import { colors } from "@/lib/theme";
|
||||
|
||||
SplashScreen.preventAutoHideAsync().catch(() => {});
|
||||
|
||||
function AuthGate() {
|
||||
const { session, hydrated, hydrate } = useAuth();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
const hydrateRepo = useRepo((s) => s.hydrate);
|
||||
const hydratePrefs = usePrefs((s) => s.hydrate);
|
||||
const hydrateSeen = useSeenReports((s) => s.hydrate);
|
||||
usePushNotifications();
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
hydratePrefs();
|
||||
}, [hydrate, hydratePrefs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useAuth.getState().session) return;
|
||||
hydrateRepo();
|
||||
hydrateSeen();
|
||||
}, [hydrateRepo, hydrateSeen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
const onLogin = segments[0] === "login";
|
||||
if (!session && !onLogin) router.replace("/login");
|
||||
if (session && onLogin) router.replace("/(drawer)");
|
||||
SplashScreen.hideAsync().catch(() => {});
|
||||
}, [hydrated, session, segments, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const identity = useAuth(sessionIdentity);
|
||||
const [fontsLoaded, fontError] = useFonts({
|
||||
"JetBrainsMono-Regular": require("../../assets/fonts/JetBrainsMono-Regular.ttf"),
|
||||
"JetBrainsMono-Medium": require("../../assets/fonts/JetBrainsMono-Medium.ttf"),
|
||||
RoundHog: require("../../assets/fonts/RoundHog.ttf"),
|
||||
"RoundHog-Medium": require("../../assets/fonts/RoundHog-Medium.ttf"),
|
||||
"RoundHog-SemiBold": require("../../assets/fonts/RoundHog-SemiBold.ttf"),
|
||||
"RoundHog-Bold": require("../../assets/fonts/RoundHog-Bold.ttf"),
|
||||
"RoundHog-Italic": require("../../assets/fonts/RoundHog-Italic.ttf"),
|
||||
});
|
||||
const reconnect = useSessions((s) => s.reconnect);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (state) => {
|
||||
if (state === "active") reconnect();
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [reconnect]);
|
||||
|
||||
// Keep going after a font error and accept fallback fonts. AuthGate hides
|
||||
// the splash screen, and it cannot mount while this returns null.
|
||||
if (!fontsLoaded && !fontError) return null;
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||
<KeyboardProvider>
|
||||
<QueryClientProvider key={identity} client={getAccountQueryClient()}>
|
||||
<StatusBar style="auto" />
|
||||
<AuthGate />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.bg },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="login" options={{ animation: "fade" }} />
|
||||
<Stack.Screen name="(drawer)" />
|
||||
<Stack.Screen
|
||||
name="config"
|
||||
options={{
|
||||
presentation: "formSheet",
|
||||
sheetAllowedDetents: [0.6, 1],
|
||||
sheetGrabberVisible: true,
|
||||
sheetCornerRadius: 32,
|
||||
contentStyle: { backgroundColor: colors.bg },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="picker"
|
||||
options={{
|
||||
presentation: "formSheet",
|
||||
sheetAllowedDetents: [0.55, 1],
|
||||
sheetGrabberVisible: true,
|
||||
sheetCornerRadius: 32,
|
||||
contentStyle: { backgroundColor: colors.bg },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
presentation: "modal",
|
||||
contentStyle: { backgroundColor: colors.bg },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<OfflinePill />
|
||||
</QueryClientProvider>
|
||||
</KeyboardProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { colors } from "@/lib/theme";
|
||||
|
||||
// The run options sheet drills down with native pushes inside the sheet.
|
||||
export default function ConfigLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.bg },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getReasoningEffortOptions } from "@posthog/shared";
|
||||
import { useRouter } from "expo-router";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { SheetHeader } from "@/components/SheetHeader";
|
||||
import { SheetRow, sheetStyles } from "@/components/SheetRow";
|
||||
import { useComposer } from "@/lib/composer";
|
||||
import { shortModelName } from "@/lib/models";
|
||||
|
||||
export default function RunOptionsMenu() {
|
||||
const router = useRouter();
|
||||
const config = useComposer();
|
||||
const efforts = getReasoningEffortOptions(config.adapter, config.model);
|
||||
const effortLabel =
|
||||
efforts?.find((option) => option.value === config.reasoning)?.name ?? "";
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={sheetStyles.root}
|
||||
contentContainerStyle={sheetStyles.list}
|
||||
>
|
||||
<SheetHeader title="Run options" />
|
||||
<View style={sheetStyles.card}>
|
||||
<SheetRow
|
||||
first
|
||||
label="Model"
|
||||
value={shortModelName(config.model)}
|
||||
onPress={() => router.push("/config/model")}
|
||||
/>
|
||||
{efforts ? (
|
||||
<SheetRow
|
||||
label="Reasoning"
|
||||
value={effortLabel}
|
||||
onPress={() => router.push("/config/reasoning")}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={sheetStyles.card}>
|
||||
<SheetRow
|
||||
first
|
||||
label="Reset to default"
|
||||
onPress={() => config.reset()}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { modelCostInfo } from "@posthog/core/billing/modelPricing";
|
||||
import { buildProviderModelGroups } from "@posthog/shared";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { SheetHeader } from "@/components/SheetHeader";
|
||||
import { SheetRow, sheetStyles } from "@/components/SheetRow";
|
||||
import { useComposer } from "@/lib/composer";
|
||||
import { shortModelName } from "@/lib/models";
|
||||
import { useModels } from "@/lib/queries";
|
||||
|
||||
export default function ModelPage() {
|
||||
const router = useRouter();
|
||||
const models = useModels();
|
||||
const model = useComposer((s) => s.model);
|
||||
const adapter = useComposer((s) => s.adapter);
|
||||
const setModel = useComposer((s) => s.setModel);
|
||||
const groups = useMemo(
|
||||
() => buildProviderModelGroups(models.data ?? [], adapter, model),
|
||||
[models.data, adapter, model],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={sheetStyles.root}
|
||||
contentContainerStyle={sheetStyles.list}
|
||||
>
|
||||
<SheetHeader title="Model" back />
|
||||
{groups.map((group) => (
|
||||
<View key={group.group} style={sheetStyles.section}>
|
||||
<Text style={sheetStyles.sectionTitle}>{group.name}</Text>
|
||||
<View style={sheetStyles.card}>
|
||||
{group.options.map((option, index) => (
|
||||
<SheetRow
|
||||
key={option.value}
|
||||
first={index === 0}
|
||||
radio={option.value === model}
|
||||
label={shortModelName(option.value)}
|
||||
trailing={modelCostInfo(option.value)?.multiplierLabel}
|
||||
onPress={() => {
|
||||
setModel(option.value);
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
<Text style={sheetStyles.footnote}>
|
||||
× is cost per token vs Claude Sonnet 5
|
||||
</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getReasoningEffortOptions } from "@posthog/shared";
|
||||
import { useRouter } from "expo-router";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { SheetHeader } from "@/components/SheetHeader";
|
||||
import { SheetRow, sheetStyles } from "@/components/SheetRow";
|
||||
import { useComposer } from "@/lib/composer";
|
||||
|
||||
export default function ReasoningPage() {
|
||||
const router = useRouter();
|
||||
const { adapter, model, reasoning, setReasoning } = useComposer();
|
||||
const options = getReasoningEffortOptions(adapter, model) ?? [];
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={sheetStyles.root}
|
||||
contentContainerStyle={sheetStyles.list}
|
||||
>
|
||||
<SheetHeader title="Reasoning" back />
|
||||
<View style={sheetStyles.card}>
|
||||
{options.map((option, index) => (
|
||||
<SheetRow
|
||||
key={option.value}
|
||||
first={index === 0}
|
||||
radio={option.value === reasoning}
|
||||
label={option.name}
|
||||
onPress={() => {
|
||||
setReasoning(option.value);
|
||||
router.back();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Glass } from "@/components/Glass";
|
||||
import { Logomark } from "@/components/Icons";
|
||||
import { DEV_EMAIL, DEV_PASSWORD } from "@/config";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import type { CloudRegion } from "@/lib/oauth";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
|
||||
const SLIDES = [
|
||||
{
|
||||
image: require("../../assets/hoggies/remote-work.png"),
|
||||
title: "Code on the go",
|
||||
body: "Kick off a task from the couch and watch the agent work through it, tool call by tool call.",
|
||||
},
|
||||
{
|
||||
image: require("../../assets/hoggies/im-the-driver.png"),
|
||||
title: "Steer your self-driving agents",
|
||||
body: "Swipe through what the agents found overnight. Dismiss the noise, start a task on the rest.",
|
||||
},
|
||||
{
|
||||
image: require("../../assets/hoggies/coding-group.png"),
|
||||
title: "See what your team is shipping",
|
||||
body: "Every space, every task, every finished run, in one feed you can read between meetings.",
|
||||
},
|
||||
];
|
||||
|
||||
const REGIONS: Array<{ key: CloudRegion; label: string }> = [
|
||||
{ key: "us", label: "🇺🇸 US Cloud" },
|
||||
{ key: "eu", label: "🇪🇺 EU Cloud" },
|
||||
];
|
||||
|
||||
const SEGMENT_PAD = 4;
|
||||
|
||||
// A glass pill that slides under the chosen region.
|
||||
function RegionSegment({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: CloudRegion;
|
||||
onChange: (region: CloudRegion) => void;
|
||||
}) {
|
||||
const [width, setWidth] = useState(0);
|
||||
const item = (width - SEGMENT_PAD * 2) / REGIONS.length;
|
||||
const index = REGIONS.findIndex((option) => option.key === value);
|
||||
const slide = useAnimatedStyle(() => ({
|
||||
width: item,
|
||||
transform: [
|
||||
{
|
||||
translateX: withSpring(index * item, {
|
||||
duration: 420,
|
||||
dampingRatio: 0.85,
|
||||
}),
|
||||
},
|
||||
],
|
||||
}));
|
||||
return (
|
||||
<Glass
|
||||
style={styles.segment}
|
||||
tint={colors.fill}
|
||||
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
|
||||
>
|
||||
{width > 0 ? (
|
||||
<Animated.View style={[styles.segmentPill, slide]}>
|
||||
<Glass interactive style={styles.pillGlass} />
|
||||
</Animated.View>
|
||||
) : null}
|
||||
{REGIONS.map((option) => {
|
||||
const active = option.key === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.key}
|
||||
onPress={() => onChange(option.key)}
|
||||
style={styles.segmentItem}
|
||||
>
|
||||
<Text
|
||||
style={[styles.segmentText, active && styles.segmentTextActive]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</Glass>
|
||||
);
|
||||
}
|
||||
|
||||
// The landing carousel: three things the app is for, then straight into
|
||||
// PostHog's own login sheet, which handles passwords, Google, GitHub and SSO.
|
||||
export default function LandingScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width } = useWindowDimensions();
|
||||
const [index, setIndex] = useState(0);
|
||||
const login = useAuth((s) => s.login);
|
||||
const loginWithOAuth = useAuth((s) => s.loginWithOAuth);
|
||||
const [region, setRegion] = useState<CloudRegion>("us");
|
||||
const [busy, setBusy] = useState<"cloud" | "local" | null>(null);
|
||||
const loginPending = useRef(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = async (
|
||||
kind: "cloud" | "local",
|
||||
attempt: () => Promise<void>,
|
||||
): Promise<void> => {
|
||||
if (loginPending.current) return;
|
||||
loginPending.current = true;
|
||||
setBusy(kind);
|
||||
setError(null);
|
||||
try {
|
||||
await attempt();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
loginPending.current = false;
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onScrollEnd = (event: NativeSyntheticEvent<NativeScrollEvent>): void =>
|
||||
setIndex(Math.round(event.nativeEvent.contentOffset.x / width));
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={[styles.brand, { paddingTop: insets.top + 24 }]}>
|
||||
<Logomark size={36} />
|
||||
<Text style={styles.wordmark}>PostHog</Text>
|
||||
</View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={onScrollEnd}
|
||||
style={styles.pager}
|
||||
>
|
||||
{SLIDES.map((slide) => (
|
||||
<View key={slide.title} style={[styles.slide, { width }]}>
|
||||
<Image
|
||||
source={slide.image}
|
||||
style={styles.hoggie}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.title}>{slide.title}</Text>
|
||||
<Text style={styles.body}>{slide.body}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
<View style={[styles.footer, { paddingBottom: insets.bottom + 20 }]}>
|
||||
<View style={styles.dots}>
|
||||
{SLIDES.map((slide, i) => (
|
||||
<View
|
||||
key={slide.title}
|
||||
style={[styles.dot, i === index && styles.dotActive]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<RegionSegment value={region} onChange={setRegion} />
|
||||
<Pressable
|
||||
onPress={() => run("cloud", () => loginWithOAuth(region))}
|
||||
disabled={!!busy}
|
||||
style={({ pressed }) => [
|
||||
busy !== null && busy !== "cloud" && { opacity: 0.4 },
|
||||
pressed && { opacity: 0.7 },
|
||||
]}
|
||||
>
|
||||
<Glass interactive tint="rgba(255,92,28,0.9)" style={styles.button}>
|
||||
{busy === "cloud" ? (
|
||||
<ActivityIndicator color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Get started</Text>
|
||||
)}
|
||||
</Glass>
|
||||
</Pressable>
|
||||
{__DEV__ ? (
|
||||
<Pressable
|
||||
onPress={() => run("local", () => login(DEV_EMAIL, DEV_PASSWORD))}
|
||||
disabled={!!busy}
|
||||
style={styles.devLink}
|
||||
>
|
||||
{busy === "local" ? (
|
||||
<ActivityIndicator size="small" color={colors.inkMute} />
|
||||
) : (
|
||||
<Text style={styles.link}>Sign in to localhost</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
brand: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
},
|
||||
wordmark: { fontFamily: fonts.serif, fontSize: 24, color: colors.ink },
|
||||
pager: { flex: 1 },
|
||||
slide: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 36,
|
||||
gap: 14,
|
||||
},
|
||||
hoggie: { width: 260, height: 260, marginBottom: 10 },
|
||||
title: {
|
||||
fontFamily: fonts.sansBold,
|
||||
fontSize: 28,
|
||||
lineHeight: 34,
|
||||
color: colors.ink,
|
||||
textAlign: "center",
|
||||
},
|
||||
body: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 16,
|
||||
lineHeight: 23,
|
||||
color: colors.inkSoft,
|
||||
textAlign: "center",
|
||||
},
|
||||
footer: { paddingHorizontal: 28, gap: 22 },
|
||||
dots: { flexDirection: "row", justifyContent: "center", gap: 8 },
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.line,
|
||||
},
|
||||
dotActive: { backgroundColor: colors.ink, width: 22 },
|
||||
button: {
|
||||
borderRadius: radius.pill,
|
||||
paddingVertical: 17,
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
},
|
||||
buttonText: { color: "#FFFFFF", fontSize: 17, fontFamily: fonts.sansSemi },
|
||||
error: {
|
||||
color: colors.danger,
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
textAlign: "center",
|
||||
},
|
||||
segment: {
|
||||
flexDirection: "row",
|
||||
borderRadius: radius.pill,
|
||||
padding: SEGMENT_PAD,
|
||||
overflow: "hidden",
|
||||
marginBottom: -8,
|
||||
},
|
||||
segmentPill: {
|
||||
position: "absolute",
|
||||
top: SEGMENT_PAD,
|
||||
bottom: SEGMENT_PAD,
|
||||
left: SEGMENT_PAD,
|
||||
},
|
||||
pillGlass: { flex: 1, borderRadius: radius.pill, overflow: "hidden" },
|
||||
segmentItem: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: "center",
|
||||
},
|
||||
segmentText: {
|
||||
fontFamily: fonts.sansMedium,
|
||||
fontSize: 15,
|
||||
color: colors.inkMute,
|
||||
},
|
||||
segmentTextActive: { color: colors.ink },
|
||||
devLink: { alignItems: "center", marginTop: -6 },
|
||||
link: { fontFamily: fonts.sansMedium, fontSize: 14, color: colors.inkMute },
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Glass } from "@/components/Glass";
|
||||
import { SheetHeader } from "@/components/SheetHeader";
|
||||
import { useDefaultRepository, useRepositories } from "@/lib/queries";
|
||||
import { useRepo } from "@/lib/repo";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
|
||||
interface Option {
|
||||
key: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export default function RepositorySheet() {
|
||||
const router = useRouter();
|
||||
const repositories = useRepositories();
|
||||
const repository = useDefaultRepository();
|
||||
const setRepository = useRepo((s) => s.setRepository);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const options = useMemo<Option[]>(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return [
|
||||
{ key: "none", label: "No repository", value: null },
|
||||
...(repositories.data ?? [])
|
||||
.filter((repo) => !needle || repo.toLowerCase().includes(needle))
|
||||
.map((repo) => ({
|
||||
key: repo,
|
||||
label: repo.split("/")[1] ?? repo,
|
||||
detail: repo.split("/")[0],
|
||||
value: repo,
|
||||
})),
|
||||
];
|
||||
}, [repositories.data, query]);
|
||||
|
||||
const selected = repository.data ?? null;
|
||||
const loading = repositories.isLoading;
|
||||
|
||||
const choose = (option: Option): void => {
|
||||
setRepository(option.value);
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.root}
|
||||
contentContainerStyle={styles.list}
|
||||
keyboardDismissMode="on-drag"
|
||||
>
|
||||
<SheetHeader title="Repository" />
|
||||
<Glass style={styles.search}>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search repositories"
|
||||
placeholderTextColor={colors.inkMute}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.searchInput}
|
||||
/>
|
||||
</Glass>
|
||||
{loading && options.length <= 1 ? (
|
||||
<Text style={styles.empty}>Loading</Text>
|
||||
) : null}
|
||||
<View style={styles.card}>
|
||||
{options.map((option, index) => {
|
||||
const active = option.value === selected;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.key}
|
||||
onPress={() => choose(option)}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
index > 0 && styles.rowDivider,
|
||||
pressed && { opacity: 0.5 },
|
||||
]}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[styles.label, active && styles.labelActive]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.detail ? (
|
||||
<Text style={styles.detail}>{option.detail}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{active ? <Text style={styles.check}>✓</Text> : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
search: {
|
||||
borderRadius: radius.pill,
|
||||
paddingHorizontal: 16,
|
||||
overflow: "hidden",
|
||||
},
|
||||
searchInput: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 16,
|
||||
color: colors.ink,
|
||||
paddingVertical: 11,
|
||||
},
|
||||
list: { padding: 18, paddingBottom: 40, gap: 12 },
|
||||
empty: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 14,
|
||||
color: colors.inkMute,
|
||||
padding: 8,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.card,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingVertical: 13,
|
||||
},
|
||||
rowDivider: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.line,
|
||||
},
|
||||
label: { fontFamily: fonts.sans, fontSize: 16, color: colors.ink },
|
||||
labelActive: { fontFamily: fonts.sansSemi },
|
||||
detail: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 13,
|
||||
color: colors.inkMute,
|
||||
marginTop: 1,
|
||||
},
|
||||
check: { fontFamily: fonts.sansBold, fontSize: 16, color: colors.accent },
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { colors } from "@/lib/theme";
|
||||
|
||||
// Settings drills down with native pushes inside the modal.
|
||||
export default function SettingsLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.bg },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import Constants from "expo-constants";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as Updates from "expo-updates";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { GlassCircleButton } from "@/components/Glass";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { unregisterPushToken } from "@/lib/notifications";
|
||||
import { type AppearanceMode, usePrefs } from "@/lib/prefs";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
|
||||
export default function SettingsSheet() {
|
||||
const router = useRouter();
|
||||
const session = useAuth((s) => s.session);
|
||||
const logout = useAuth((s) => s.logout);
|
||||
const hedgehogMode = usePrefs((s) => s.hedgehogMode);
|
||||
const appearance = usePrefs((s) => s.appearance);
|
||||
const setPrefs = usePrefs((s) => s.set);
|
||||
const initials = (session?.userName ?? "").slice(0, 2).toUpperCase();
|
||||
|
||||
const finishLogout = async (): Promise<void> => {
|
||||
router.back();
|
||||
await logout();
|
||||
};
|
||||
|
||||
const signOut = async (): Promise<void> => {
|
||||
if (await unregisterPushToken()) {
|
||||
await finishLogout();
|
||||
return;
|
||||
}
|
||||
Alert.alert(
|
||||
"Could not turn off notifications",
|
||||
"This device can still get notifications for this account. Check your connection and try again.",
|
||||
[
|
||||
{
|
||||
text: "Log out anyway",
|
||||
style: "destructive",
|
||||
onPress: () => void finishLogout(),
|
||||
},
|
||||
{ text: "Try again", style: "cancel", onPress: () => void signOut() },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<GlassCircleButton size={44} onPress={() => router.back()}>
|
||||
<Text style={styles.close}>×</Text>
|
||||
</GlassCircleButton>
|
||||
<Text style={styles.title}>Settings</Text>
|
||||
<View style={{ width: 44 }} />
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Pressable
|
||||
onPress={() => router.push("/settings/projects")}
|
||||
style={({ pressed }) => [
|
||||
styles.accountRow,
|
||||
pressed && { opacity: 0.5 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>{initials}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.email} numberOfLines={1}>
|
||||
{session?.email ?? session?.userName}
|
||||
</Text>
|
||||
<Text style={styles.sub} numberOfLines={1}>
|
||||
{session?.projectName}
|
||||
{session
|
||||
? ` · ${session.region === "local" ? "Local" : session.region.toUpperCase()}`
|
||||
: ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.chevron}>›</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={styles.section}>Appearance</Text>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.modes}>
|
||||
{MODES.map((mode) => (
|
||||
<ModeOption
|
||||
key={mode.value}
|
||||
mode={mode}
|
||||
selected={appearance === mode.value}
|
||||
onPress={() => setPrefs({ appearance: mode.value })}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowDivided]}>
|
||||
<Text style={styles.rowLabel}>Hedgehog mode</Text>
|
||||
<Switch
|
||||
value={hedgehogMode}
|
||||
onValueChange={(value) => setPrefs({ hedgehogMode: value })}
|
||||
trackColor={{ true: colors.accent }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.section}>About</Text>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Version</Text>
|
||||
<Text style={styles.rowValue}>
|
||||
{Constants.expoConfig?.version}
|
||||
{Constants.expoConfig?.ios?.buildNumber
|
||||
? ` (${Constants.expoConfig.ios.buildNumber})`
|
||||
: ""}
|
||||
</Text>
|
||||
</View>
|
||||
{Updates.isEnabled ? <UpdateRow /> : null}
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => void signOut()}
|
||||
style={({ pressed }) => [styles.logout, pressed && { opacity: 0.7 }]}
|
||||
>
|
||||
<Text style={styles.logoutText}>Log out</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const MODES: { value: AppearanceMode; label: string }[] = [
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "system", label: "System" },
|
||||
];
|
||||
|
||||
// A tiny phone-screen mockup for each scheme; System is split diagonally.
|
||||
function ModeOption({
|
||||
mode,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
mode: (typeof MODES)[number];
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const Half = ({ dark }: { dark: boolean }) => (
|
||||
<View style={[styles.thumb, dark ? styles.thumbDark : styles.thumbLight]}>
|
||||
<View style={[styles.thumbCard, dark && styles.thumbCardDark]}>
|
||||
<View style={[styles.thumbLine, { width: 36 }]} />
|
||||
<View style={[styles.thumbLine, { width: 26 }]} />
|
||||
<View style={styles.thumbDot} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<Pressable onPress={onPress} style={styles.mode}>
|
||||
<View style={[styles.thumbFrame, selected && styles.thumbSelected]}>
|
||||
<Half dark={mode.value === "dark"} />
|
||||
{mode.value === "system" ? (
|
||||
<View style={styles.diagonal}>
|
||||
<View style={styles.diagonalInner}>
|
||||
<Half dark />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[styles.modeLabel, selected && styles.modeLabelSelected]}>
|
||||
{mode.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const THUMB_W = 96;
|
||||
const THUMB_H = 64;
|
||||
|
||||
type UpdateState = "idle" | "checking" | "latest" | "ready" | "failed";
|
||||
|
||||
const UPDATE_STATUS: Record<UpdateState, string> = {
|
||||
idle: "",
|
||||
checking: "Checking…",
|
||||
latest: "Up to date",
|
||||
ready: "Restart",
|
||||
failed: "Try again",
|
||||
};
|
||||
|
||||
function UpdateRow() {
|
||||
const [state, setState] = useState<UpdateState>("idle");
|
||||
const onPress = async (): Promise<void> => {
|
||||
if (state === "ready") {
|
||||
await Updates.reloadAsync();
|
||||
return;
|
||||
}
|
||||
setState("checking");
|
||||
try {
|
||||
const result = await Updates.checkForUpdateAsync();
|
||||
if (!result.isAvailable) {
|
||||
setState("latest");
|
||||
return;
|
||||
}
|
||||
await Updates.fetchUpdateAsync();
|
||||
setState("ready");
|
||||
} catch {
|
||||
setState("failed");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={state === "checking"}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
styles.rowDivided,
|
||||
pressed && { opacity: 0.5 },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.rowLabel}>
|
||||
{state === "ready" ? "Update ready" : "Check for updates"}
|
||||
</Text>
|
||||
<Text style={[styles.rowValue, state === "ready" && styles.rowAccent]}>
|
||||
{UPDATE_STATUS[state]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modes: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
mode: { alignItems: "center", gap: 8 },
|
||||
thumbFrame: {
|
||||
width: THUMB_W,
|
||||
height: THUMB_H,
|
||||
borderRadius: 14,
|
||||
overflow: "hidden",
|
||||
borderWidth: 2,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
thumbSelected: { borderColor: colors.accent },
|
||||
thumb: {
|
||||
width: THUMB_W - 4,
|
||||
height: THUMB_H - 4,
|
||||
padding: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
thumbLight: { backgroundColor: "#F4F4F0" },
|
||||
thumbDark: { backgroundColor: "#0C0C0C" },
|
||||
thumbCard: {
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#FFFFFF",
|
||||
padding: 7,
|
||||
gap: 4,
|
||||
},
|
||||
thumbCardDark: { backgroundColor: "#1C1C1C" },
|
||||
thumbLine: { height: 4, borderRadius: 2, backgroundColor: "#8F918D" },
|
||||
thumbDot: {
|
||||
position: "absolute",
|
||||
right: 8,
|
||||
bottom: 7,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
// Dark half of the System thumb: an oversized clip rotated to the diagonal
|
||||
// and pushed below it, with the content counter-transformed back into place.
|
||||
diagonal: {
|
||||
position: "absolute",
|
||||
left: -THUMB_W / 2,
|
||||
top: -THUMB_H / 2,
|
||||
width: THUMB_W * 2,
|
||||
height: THUMB_H * 2,
|
||||
overflow: "hidden",
|
||||
transform: [{ rotate: "-34deg" }, { translateY: THUMB_H }],
|
||||
},
|
||||
diagonalInner: {
|
||||
position: "absolute",
|
||||
left: THUMB_W / 2,
|
||||
top: THUMB_H / 2,
|
||||
transform: [{ translateY: -THUMB_H }, { rotate: "34deg" }],
|
||||
},
|
||||
modeLabel: { fontFamily: fonts.sans, fontSize: 15, color: colors.ink },
|
||||
modeLabelSelected: { color: colors.accent },
|
||||
rowDivided: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.line,
|
||||
},
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
content: { padding: 18, paddingTop: 22, gap: 14, paddingBottom: 40 },
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 6,
|
||||
},
|
||||
close: { fontSize: 26, lineHeight: 28, color: colors.ink, marginTop: -2 },
|
||||
title: { fontFamily: fonts.sansSemi, fontSize: 17, color: colors.ink },
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.card,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
accountRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
avatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.bgDeep,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
avatarText: {
|
||||
fontFamily: fonts.sansBold,
|
||||
fontSize: 13,
|
||||
color: colors.accent,
|
||||
},
|
||||
email: { fontFamily: fonts.sansMedium, fontSize: 16, color: colors.ink },
|
||||
sub: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 13,
|
||||
color: colors.inkMute,
|
||||
marginTop: 2,
|
||||
},
|
||||
chevron: { fontSize: 20, lineHeight: 22, color: colors.inkMute },
|
||||
section: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 14,
|
||||
color: colors.inkMute,
|
||||
marginTop: 8,
|
||||
marginLeft: 6,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 10,
|
||||
},
|
||||
rowLabel: { fontFamily: fonts.sans, fontSize: 16, color: colors.ink },
|
||||
logout: {
|
||||
marginTop: 16,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.card,
|
||||
paddingVertical: 16,
|
||||
alignItems: "center",
|
||||
},
|
||||
logoutText: {
|
||||
fontFamily: fonts.sansSemi,
|
||||
fontSize: 16,
|
||||
color: colors.danger,
|
||||
},
|
||||
rowValue: { fontFamily: fonts.sans, fontSize: 16, color: colors.inkMute },
|
||||
rowAccent: { fontFamily: fonts.sansMedium, color: colors.accent },
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { ActivityIndicator, Alert, ScrollView, Text, View } from "react-native";
|
||||
import { SheetHeader } from "@/components/SheetHeader";
|
||||
import { SheetRow, sheetStyles } from "@/components/SheetRow";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useProjects } from "@/lib/projects";
|
||||
import { colors } from "@/lib/theme";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const router = useRouter();
|
||||
const projects = useProjects();
|
||||
const currentId = useAuth((s) => s.session?.projectId);
|
||||
const selectProject = useAuth((s) => s.selectProject);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={sheetStyles.root}
|
||||
contentContainerStyle={sheetStyles.list}
|
||||
>
|
||||
<SheetHeader title="Projects" back />
|
||||
{projects.isLoading ? (
|
||||
<ActivityIndicator color={colors.inkSoft} style={{ marginTop: 24 }} />
|
||||
) : null}
|
||||
{projects.isError ? (
|
||||
<Text style={sheetStyles.footnote}>
|
||||
Could not load projects. Go back and try again.
|
||||
</Text>
|
||||
) : null}
|
||||
{projects.data?.length ? (
|
||||
<View style={sheetStyles.card}>
|
||||
{projects.data.map((project, index) => (
|
||||
<SheetRow
|
||||
key={project.id}
|
||||
first={index === 0}
|
||||
checked={project.id === currentId}
|
||||
label={project.name}
|
||||
onPress={async () => {
|
||||
try {
|
||||
await selectProject(project.id, project.name);
|
||||
router.back();
|
||||
} catch {
|
||||
Alert.alert(
|
||||
"Could not switch project",
|
||||
"Check your connection and try again.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Button, Host, Image, Menu } from "@expo/ui/swift-ui";
|
||||
import { frame } from "@expo/ui/swift-ui/modifiers";
|
||||
import type { Task } from "@posthog/shared/domain-types";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { Pressable, StyleSheet, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Glass, GlassCircleButton } from "@/components/Glass";
|
||||
import { MenuIcon } from "@/components/Icons";
|
||||
import { useTaskActions } from "@/components/TaskRow";
|
||||
import { colors } from "@/lib/theme";
|
||||
|
||||
const BUTTON = 46;
|
||||
|
||||
interface ChatHeaderProps {
|
||||
showNewChat?: boolean;
|
||||
// The open chat's task, for the options menu beside new chat.
|
||||
task?: Task;
|
||||
}
|
||||
|
||||
export function ChatHeader({ showNewChat = true, task }: ChatHeaderProps) {
|
||||
const navigation = useNavigation<{ openDrawer: () => void }>();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { rename, setArchived } = useTaskActions(task);
|
||||
return (
|
||||
<View
|
||||
style={[styles.root, { paddingTop: insets.top + 6 }]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<GlassCircleButton onPress={() => navigation.openDrawer()}>
|
||||
<MenuIcon />
|
||||
</GlassCircleButton>
|
||||
<View style={{ flex: 1 }} pointerEvents="none" />
|
||||
{showNewChat ? (
|
||||
<Glass interactive style={styles.pair}>
|
||||
<Pressable
|
||||
onPress={() => router.replace("/(drawer)")}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => [
|
||||
styles.pairButton,
|
||||
pressed && { opacity: 0.6 },
|
||||
]}
|
||||
>
|
||||
<Host matchContents>
|
||||
<Image
|
||||
systemName="square.and.pencil"
|
||||
size={18}
|
||||
color={colors.ink}
|
||||
/>
|
||||
</Host>
|
||||
</Pressable>
|
||||
{task ? (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<Host matchContents>
|
||||
<Menu
|
||||
label={
|
||||
<Image systemName="ellipsis" size={18} color={colors.ink} />
|
||||
}
|
||||
modifiers={[frame({ width: BUTTON, height: BUTTON })]}
|
||||
>
|
||||
<Button
|
||||
label="Rename"
|
||||
systemImage="pencil"
|
||||
onPress={rename}
|
||||
/>
|
||||
<Button
|
||||
label="Archive"
|
||||
systemImage="archivebox"
|
||||
onPress={() =>
|
||||
setArchived(true, () => router.replace("/(drawer)"))
|
||||
}
|
||||
/>
|
||||
</Menu>
|
||||
</Host>
|
||||
</>
|
||||
) : null}
|
||||
</Glass>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
pair: {
|
||||
height: BUTTON,
|
||||
borderRadius: BUTTON / 2,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
},
|
||||
pairButton: {
|
||||
width: BUTTON,
|
||||
height: BUTTON,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
divider: {
|
||||
width: StyleSheet.hairlineWidth,
|
||||
height: 20,
|
||||
backgroundColor: colors.line,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Host, Image as SymbolImage } from "@expo/ui/swift-ui";
|
||||
import { getReasoningEffortOptions } from "@posthog/shared";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
type ColorValue,
|
||||
Image,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
LinearTransition,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "react-native-reanimated";
|
||||
import { Glass } from "@/components/Glass";
|
||||
import { ArrowUpIcon, StopIcon } from "@/components/Icons";
|
||||
import { Waveform } from "@/components/Waveform";
|
||||
import { MAX_PHOTOS, type Photo, pickPhotos } from "@/lib/attachments";
|
||||
import { useComposer } from "@/lib/composer";
|
||||
import { useDictation } from "@/lib/dictation";
|
||||
import { shortModelName } from "@/lib/models";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
|
||||
function Glyph({
|
||||
name,
|
||||
size = 17,
|
||||
color = colors.ink,
|
||||
}: {
|
||||
name: "plus" | "mic" | "xmark" | "stop.fill";
|
||||
size?: number;
|
||||
color?: ColorValue;
|
||||
}) {
|
||||
return (
|
||||
<Host matchContents>
|
||||
<SymbolImage systemName={name} size={size} color={color} />
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
interface ComposerProps {
|
||||
placeholder: string;
|
||||
// Shown as a second pill when provided (null = no repository chosen).
|
||||
repository?: string | null;
|
||||
onSend: (text: string, photos: Photo[]) => void | Promise<void>;
|
||||
onStop?: () => void;
|
||||
busy?: boolean;
|
||||
sending?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
placeholder,
|
||||
repository,
|
||||
onSend,
|
||||
onStop,
|
||||
busy,
|
||||
sending,
|
||||
autoFocus,
|
||||
}: ComposerProps) {
|
||||
const router = useRouter();
|
||||
const [text, setText] = useState("");
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
const dictation = useDictation();
|
||||
const withSpeech = (base: string, heard: string): string =>
|
||||
[base.trim(), heard.trim()].filter(Boolean).join(" ");
|
||||
const { model, adapter, reasoning } = useComposer();
|
||||
const effort = getReasoningEffortOptions(adapter, model)?.find(
|
||||
(option) => option.value === reasoning,
|
||||
)?.name;
|
||||
const shown = dictation.active
|
||||
? withSpeech(text, dictation.transcript)
|
||||
: text;
|
||||
const canSend = (shown.trim().length > 0 || photos.length > 0) && !sending;
|
||||
|
||||
const attach = async (): Promise<void> => {
|
||||
try {
|
||||
const picked = await pickPhotos(MAX_PHOTOS - photos.length);
|
||||
if (picked.length) setPhotos((current) => [...current, ...picked]);
|
||||
} catch {
|
||||
Alert.alert("Could not open photos", "Check photo access in Settings.");
|
||||
}
|
||||
};
|
||||
|
||||
const startDictation = (): void => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
dictation.start();
|
||||
};
|
||||
|
||||
const stopDictation = (): string => {
|
||||
const value = withSpeech(text, dictation.stop());
|
||||
setText(value);
|
||||
return value;
|
||||
};
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
const value = (dictation.active ? stopDictation() : text).trim();
|
||||
if ((!value && !photos.length) || sending) return;
|
||||
const attached = photos;
|
||||
setText("");
|
||||
setPhotos([]);
|
||||
try {
|
||||
await onSend(value, attached);
|
||||
} catch {
|
||||
// Keep a draft the person started while the send was in flight.
|
||||
setText((current) => current || value);
|
||||
setPhotos((current) => (current.length ? current : attached));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Glass style={styles.shell}>
|
||||
{photos.length ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.thumbs}
|
||||
>
|
||||
{photos.map((photo) => (
|
||||
<Animated.View
|
||||
key={photo.id}
|
||||
entering={ZoomIn.duration(180)}
|
||||
exiting={ZoomOut.duration(140)}
|
||||
layout={LinearTransition.duration(180)}
|
||||
>
|
||||
<Image source={{ uri: photo.uri }} style={styles.thumb} />
|
||||
<Pressable
|
||||
accessibilityLabel="Remove photo"
|
||||
hitSlop={8}
|
||||
onPress={() =>
|
||||
setPhotos((current) =>
|
||||
current.filter((item) => item.id !== photo.id),
|
||||
)
|
||||
}
|
||||
style={styles.thumbRemove}
|
||||
>
|
||||
<Glyph name="xmark" size={10} color={colors.darkText} />
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
<TextInput
|
||||
value={shown}
|
||||
onChangeText={setText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.inkMute}
|
||||
style={styles.input}
|
||||
multiline
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
{dictation.active ? (
|
||||
<Pressable
|
||||
accessibilityLabel="Cancel dictation"
|
||||
onPress={dictation.cancel}
|
||||
style={({ pressed }) => [
|
||||
styles.circle,
|
||||
pressed && { opacity: 0.6 },
|
||||
]}
|
||||
>
|
||||
<Glyph name="xmark" />
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
accessibilityLabel="Add photos"
|
||||
onPress={attach}
|
||||
disabled={photos.length >= MAX_PHOTOS}
|
||||
style={({ pressed }) => [
|
||||
styles.circle,
|
||||
pressed && { opacity: 0.6 },
|
||||
]}
|
||||
>
|
||||
<Glyph name="plus" />
|
||||
</Pressable>
|
||||
)}
|
||||
{dictation.active ? (
|
||||
<Animated.View entering={FadeIn.duration(200)} style={styles.middle}>
|
||||
<Waveform levels={dictation.levels} head={dictation.head} />
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(120)}
|
||||
style={styles.middle}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => router.push("/config")}
|
||||
style={({ pressed }) => [
|
||||
styles.pill,
|
||||
pressed && { opacity: 0.6 },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.pillText} numberOfLines={1}>
|
||||
{shortModelName(model)}
|
||||
{effort ? (
|
||||
<Text style={styles.pillMuted}> {effort}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{repository !== undefined ? (
|
||||
<Pressable
|
||||
onPress={() => router.push("/picker")}
|
||||
style={({ pressed }) => [
|
||||
styles.pill,
|
||||
styles.pillWide,
|
||||
pressed && { opacity: 0.6 },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.pillText} numberOfLines={1}>
|
||||
{repository
|
||||
? (repository.split("/")[1] ?? repository)
|
||||
: "No repo"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
)}
|
||||
<Pressable
|
||||
accessibilityLabel={dictation.active ? "Stop dictation" : "Dictate"}
|
||||
onPress={dictation.active ? stopDictation : startDictation}
|
||||
style={({ pressed }) => [styles.circle, pressed && { opacity: 0.6 }]}
|
||||
>
|
||||
<Glyph
|
||||
name={dictation.active ? "stop.fill" : "mic"}
|
||||
size={dictation.active ? 14 : 17}
|
||||
/>
|
||||
</Pressable>
|
||||
{busy && onStop ? (
|
||||
<Pressable
|
||||
onPress={onStop}
|
||||
style={({ pressed }) => [styles.send, pressed && { opacity: 0.7 }]}
|
||||
>
|
||||
<StopIcon />
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={submit}
|
||||
disabled={!canSend}
|
||||
style={({ pressed }) => [
|
||||
styles.send,
|
||||
!canSend && styles.sendDisabled,
|
||||
pressed && { opacity: 0.7 },
|
||||
]}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator size="small" color={colors.darkText} />
|
||||
) : (
|
||||
<ArrowUpIcon />
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</Glass>
|
||||
);
|
||||
}
|
||||
|
||||
const CONTROL = 36;
|
||||
|
||||
const THUMB = 64;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
thumbs: { gap: 8, paddingTop: 2, paddingRight: 8 },
|
||||
thumb: {
|
||||
width: THUMB,
|
||||
height: THUMB,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.fill,
|
||||
},
|
||||
thumbRemove: {
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "rgba(21, 21, 21, 0.6)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
shell: {
|
||||
borderRadius: 28,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 14,
|
||||
paddingBottom: 10,
|
||||
gap: 10,
|
||||
overflow: "hidden",
|
||||
},
|
||||
input: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 17,
|
||||
lineHeight: 22,
|
||||
color: colors.ink,
|
||||
maxHeight: 140,
|
||||
paddingTop: 0,
|
||||
},
|
||||
row: { flexDirection: "row", alignItems: "center", gap: 6 },
|
||||
circle: {
|
||||
width: CONTROL,
|
||||
height: CONTROL,
|
||||
borderRadius: CONTROL / 2,
|
||||
backgroundColor: colors.fill,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
pill: {
|
||||
height: CONTROL,
|
||||
justifyContent: "center",
|
||||
backgroundColor: colors.fill,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
// Only the repository gives way when the row runs out of room.
|
||||
pillWide: { flexShrink: 1, maxWidth: 130 },
|
||||
pillText: { fontFamily: fonts.sansMedium, fontSize: 14, color: colors.ink },
|
||||
pillMuted: { color: colors.inkMute },
|
||||
send: {
|
||||
width: CONTROL,
|
||||
height: CONTROL,
|
||||
borderRadius: CONTROL / 2,
|
||||
backgroundColor: colors.dark,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sendDisabled: { opacity: 0.3 },
|
||||
// Holds the pills or the waveform between the left button and the mic.
|
||||
middle: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { Host, Image } from "@expo/ui/swift-ui";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { DrawerEdgeShadow } from "@/components/DrawerEdgeShadow";
|
||||
import { FadeScrim } from "@/components/FadeScrim";
|
||||
import { GlassCircleButton } from "@/components/Glass";
|
||||
import { BellIcon, SteeringIcon } from "@/components/Icons";
|
||||
import { activityAt, RowSkeletons, TaskRow } from "@/components/TaskRow";
|
||||
import { useActivity } from "@/lib/activity";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { useTasks } from "@/lib/queries";
|
||||
import { useReports, useSeenReports } from "@/lib/reports";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
|
||||
export function DrawerContent({ closeDrawer }: { closeDrawer: () => void }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const tasks = useTasks();
|
||||
const userName = useAuth((s) => s.session?.userName ?? "");
|
||||
const unread = useActivity().data?.unread_count ?? 0;
|
||||
const reports = useReports().data ?? [];
|
||||
const seenReports = useSeenReports((s) => s.seen);
|
||||
const newReports = reports.filter(
|
||||
(report) => !seenReports.has(report.id),
|
||||
).length;
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...(tasks.data ?? [])].sort((a, b) =>
|
||||
activityAt(b).localeCompare(activityAt(a)),
|
||||
),
|
||||
[tasks.data],
|
||||
);
|
||||
|
||||
const open = (taskId: string): void => {
|
||||
closeDrawer();
|
||||
router.push({ pathname: "/(drawer)/task/[id]", params: { id: taskId } });
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.root, { paddingTop: insets.top + 6 }]}>
|
||||
<DrawerEdgeShadow />
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.wordmark}>PostHog</Text>
|
||||
<GlassCircleButton
|
||||
onPress={() => {
|
||||
closeDrawer();
|
||||
router.push("/(drawer)/recents");
|
||||
}}
|
||||
>
|
||||
<Host matchContents>
|
||||
<Image systemName="magnifyingglass" size={18} color={colors.ink} />
|
||||
</Host>
|
||||
</GlassCircleButton>
|
||||
</View>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scroll,
|
||||
{ paddingBottom: FOOTER_HEIGHT + insets.bottom + 24 },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
closeDrawer();
|
||||
router.push("/(drawer)/activity");
|
||||
}}
|
||||
style={({ pressed }) => [styles.navRow, pressed && { opacity: 0.5 }]}
|
||||
>
|
||||
<BellIcon />
|
||||
<Text style={styles.navLabel}>Activity</Text>
|
||||
{unread > 0 ? (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{unread}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
closeDrawer();
|
||||
router.push("/(drawer)/self-driving");
|
||||
}}
|
||||
style={({ pressed }) => [styles.navRow, pressed && { opacity: 0.5 }]}
|
||||
>
|
||||
<SteeringIcon />
|
||||
<Text style={styles.navLabel}>Self-driving</Text>
|
||||
{newReports > 0 ? (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{newReports}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
<View style={styles.group}>
|
||||
<Text style={styles.groupTitle}>Recent tasks</Text>
|
||||
{tasks.isLoading && sorted.length === 0 ? <RowSkeletons /> : null}
|
||||
{sorted.map((task) => (
|
||||
<TaskRow key={task.id} task={task} onPress={() => open(task.id)} />
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<View
|
||||
style={[styles.footer, { paddingBottom: insets.bottom + 12 }]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<FadeScrim style={styles.footerScrim} color={colors.bgDeep} />
|
||||
<GlassCircleButton
|
||||
size={FOOTER_HEIGHT}
|
||||
onPress={() => router.push("/settings")}
|
||||
>
|
||||
<Text style={styles.avatarText}>
|
||||
{userName.slice(0, 2).toUpperCase()}
|
||||
</Text>
|
||||
</GlassCircleButton>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
closeDrawer();
|
||||
router.replace("/(drawer)");
|
||||
}}
|
||||
style={({ pressed }) => [styles.newTask, pressed && { opacity: 0.8 }]}
|
||||
>
|
||||
<Text style={styles.newTaskPlus}>+</Text>
|
||||
<Text style={styles.newTaskText}>New task</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const FOOTER_HEIGHT = 52;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgDeep,
|
||||
paddingLeft: 18,
|
||||
paddingRight: 18 + 72,
|
||||
marginRight: -72,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 18,
|
||||
},
|
||||
wordmark: {
|
||||
fontFamily: fonts.sansBold,
|
||||
fontSize: 30,
|
||||
color: colors.ink,
|
||||
marginLeft: 4,
|
||||
},
|
||||
scroll: { paddingBottom: 24, gap: 18 },
|
||||
navRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingVertical: 8,
|
||||
paddingLeft: 4,
|
||||
},
|
||||
navLabel: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.sansMedium,
|
||||
fontSize: 17,
|
||||
color: colors.ink,
|
||||
},
|
||||
badge: {
|
||||
minWidth: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: colors.accent,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
badgeText: { fontFamily: fonts.sansSemi, fontSize: 12, color: "#FFFFFF" },
|
||||
group: { gap: 2 },
|
||||
groupTitle: {
|
||||
fontFamily: fonts.sansSemi,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1.2,
|
||||
textTransform: "uppercase",
|
||||
color: colors.inkMute,
|
||||
paddingLeft: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
footer: {
|
||||
position: "absolute",
|
||||
left: 18,
|
||||
right: 18 + 72,
|
||||
bottom: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingTop: 12,
|
||||
},
|
||||
footerScrim: {
|
||||
position: "absolute",
|
||||
left: -18,
|
||||
right: -(18 + 72),
|
||||
top: -36,
|
||||
bottom: 0,
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 14,
|
||||
fontFamily: fonts.sansBold,
|
||||
color: colors.ink,
|
||||
},
|
||||
newTask: {
|
||||
height: FOOTER_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
backgroundColor: colors.dark,
|
||||
paddingLeft: 18,
|
||||
paddingRight: 24,
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
newTaskPlus: {
|
||||
color: colors.darkText,
|
||||
fontSize: 26,
|
||||
lineHeight: 28,
|
||||
fontFamily: fonts.sansMedium,
|
||||
marginTop: -2,
|
||||
},
|
||||
newTaskText: {
|
||||
color: colors.darkText,
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.sansSemi,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useDrawerProgress } from "expo-router/drawer";
|
||||
import { StyleSheet, useWindowDimensions } from "react-native";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
} from "react-native-reanimated";
|
||||
import { colors, drawer } from "@/lib/theme";
|
||||
|
||||
// The chat layer lives in a native screen that clips its shadow. This ghost
|
||||
// sits in the drawer exactly where the open chat layer lands, so only its
|
||||
// shadow shows, and it follows the same rounded corner.
|
||||
export function DrawerEdgeShadow() {
|
||||
const { width, height } = useWindowDimensions();
|
||||
const progress = useDrawerProgress() as SharedValue<number>;
|
||||
const fade = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(progress.value, [0, 1], [0, 1]),
|
||||
}));
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
styles.ghost,
|
||||
{ left: width * drawer.widthFraction, width, height },
|
||||
fade,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
ghost: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
borderRadius: drawer.sceneRadius,
|
||||
backgroundColor: colors.bg,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.14,
|
||||
shadowRadius: 28,
|
||||
shadowOffset: { width: -6, height: 0 },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useDrawerProgress } from "expo-router/drawer";
|
||||
import type { ReactNode } from "react";
|
||||
import { StyleSheet } from "react-native";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
} from "react-native-reanimated";
|
||||
import { Glass } from "@/components/Glass";
|
||||
import { colors, drawer } from "@/lib/theme";
|
||||
|
||||
// The chat surface is a frosted layer that slides over the menu: it dims and
|
||||
// rounds its corner while the drawer is open. Its shadow is painted by the
|
||||
// drawer (see DrawerEdgeShadow), because the native screen clips to bounds.
|
||||
export function DrawerScene({ children }: { children: ReactNode }) {
|
||||
const progress = useDrawerProgress() as SharedValue<number>;
|
||||
const clip = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(progress.value, [0, 1], [1, 0.82]),
|
||||
borderRadius: interpolate(progress.value, [0, 1], [0, drawer.sceneRadius]),
|
||||
}));
|
||||
// Solid while closed so the drawer never ghosts through; fades out as the
|
||||
// drawer opens to let the glass edge frost it.
|
||||
const backing = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(progress.value, [0, 1], [1, 0]),
|
||||
}));
|
||||
return (
|
||||
<Animated.View style={[styles.clip, clip]}>
|
||||
<Animated.View
|
||||
style={[StyleSheet.absoluteFill, styles.backing, backing]}
|
||||
/>
|
||||
<Glass style={styles.scene} tint={colors.sceneTint}>
|
||||
{children}
|
||||
</Glass>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
clip: { flex: 1, overflow: "hidden" },
|
||||
backing: { backgroundColor: colors.bg },
|
||||
scene: { flex: 1 },
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
type ColorValue,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { colors } from "@/lib/theme";
|
||||
|
||||
const STRIPS = 28;
|
||||
|
||||
// Fades content out under the composer: clear at the top, solid at the
|
||||
// bottom. Stacked strips stand in for a gradient so no native module is needed.
|
||||
export function FadeScrim({
|
||||
style,
|
||||
color = colors.bg,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>;
|
||||
color?: ColorValue;
|
||||
}) {
|
||||
return (
|
||||
<View pointerEvents="none" style={[styles.root, style]}>
|
||||
{Array.from({ length: STRIPS }, (_, index) => {
|
||||
const t = (index + 1) / STRIPS;
|
||||
return (
|
||||
<View
|
||||
key={String(index)}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: color,
|
||||
opacity: Math.min(1, t * t * 1.05),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flexDirection: "column" },
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
type ColorValue,
|
||||
type LayoutChangeEvent,
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { colors } from "@/lib/theme";
|
||||
|
||||
const liquid = isLiquidGlassAvailable();
|
||||
|
||||
interface GlassProps {
|
||||
children?: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
interactive?: boolean;
|
||||
tint?: ColorValue;
|
||||
onLayout?: (event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
// Real Liquid Glass on iOS 26, a translucent card everywhere else.
|
||||
export function Glass({
|
||||
children,
|
||||
style,
|
||||
interactive,
|
||||
tint,
|
||||
onLayout,
|
||||
}: GlassProps) {
|
||||
if (liquid) {
|
||||
return (
|
||||
<GlassView
|
||||
glassEffectStyle="regular"
|
||||
isInteractive={interactive}
|
||||
tintColor={tint}
|
||||
style={style}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{children}
|
||||
</GlassView>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.fallback,
|
||||
style,
|
||||
tint != null && { backgroundColor: tint },
|
||||
]}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface GlassButtonProps {
|
||||
children: ReactNode;
|
||||
onPress: () => void;
|
||||
size?: number;
|
||||
disabled?: boolean;
|
||||
tint?: ColorValue;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export function GlassCircleButton({
|
||||
children,
|
||||
onPress,
|
||||
size = 46,
|
||||
disabled,
|
||||
tint,
|
||||
style,
|
||||
}: GlassButtonProps) {
|
||||
return (
|
||||
<Pressable onPress={onPress} disabled={disabled} hitSlop={8}>
|
||||
{({ pressed }) => (
|
||||
<Glass
|
||||
interactive
|
||||
tint={tint}
|
||||
style={[
|
||||
styles.circle,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Glass>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fallback: {
|
||||
backgroundColor: colors.glass,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.line,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
},
|
||||
circle: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
// Sprite strips from @posthog/hedgehog-mode's default skin, 80px frames.
|
||||
const WALK = {
|
||||
source: require("../../assets/hedgehog/walk-strip.png"),
|
||||
frames: 11,
|
||||
fps: 30,
|
||||
};
|
||||
const IDLE = {
|
||||
source: require("../../assets/hedgehog/idle-strip.png"),
|
||||
frames: 25,
|
||||
fps: 6,
|
||||
};
|
||||
|
||||
const SIZE = 56;
|
||||
const SPEED = 70; // px per second
|
||||
|
||||
interface HedgehogProps {
|
||||
walking: boolean;
|
||||
}
|
||||
|
||||
// Paces back and forth while the agent thinks or runs tools, idles otherwise.
|
||||
// Everything animates on the UI thread, so the JS thread stays free for input.
|
||||
export function Hedgehog({ walking }: HedgehogProps) {
|
||||
const [width, setWidth] = useState(0);
|
||||
const anim = walking ? WALK : IDLE;
|
||||
const frame = useSharedValue(0);
|
||||
const x = useSharedValue(0);
|
||||
const previous = useSharedValue(0);
|
||||
const heading = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
frame.value = 0;
|
||||
frame.value = withRepeat(
|
||||
withTiming(anim.frames, {
|
||||
duration: (anim.frames / anim.fps) * 1000,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
);
|
||||
return () => cancelAnimation(frame);
|
||||
}, [anim, frame]);
|
||||
|
||||
useEffect(() => {
|
||||
const max = Math.max(0, width - SIZE);
|
||||
if (!walking || max === 0) {
|
||||
cancelAnimation(x);
|
||||
return;
|
||||
}
|
||||
// Finish the current leg from wherever we are, then bounce between the edges.
|
||||
const toEnd = ((max - x.value) / SPEED) * 1000;
|
||||
const leg = (max / SPEED) * 1000;
|
||||
x.value = withSequence(
|
||||
withTiming(max, { duration: toEnd, easing: Easing.linear }),
|
||||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: leg, easing: Easing.linear }),
|
||||
withTiming(max, { duration: leg, easing: Easing.linear }),
|
||||
),
|
||||
-1,
|
||||
),
|
||||
);
|
||||
}, [walking, width, x]);
|
||||
|
||||
// Face the direction of travel; the sprites are drawn facing right.
|
||||
const facing = useDerivedValue(() => {
|
||||
if (x.value !== previous.value) {
|
||||
heading.value = x.value > previous.value ? 1 : -1;
|
||||
previous.value = x.value;
|
||||
}
|
||||
return heading.value;
|
||||
});
|
||||
const sprite = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: x.value }, { scaleX: facing.value }],
|
||||
}));
|
||||
const strip = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: -Math.floor(frame.value % anim.frames) * SIZE }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.track}
|
||||
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Animated.View style={[styles.sprite, sprite]}>
|
||||
<Animated.Image
|
||||
source={anim.source}
|
||||
style={[{ width: SIZE * anim.frames, height: SIZE }, strip]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
track: { height: SIZE, marginBottom: -6, zIndex: 1 },
|
||||
sprite: { width: SIZE, height: SIZE, overflow: "hidden" },
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { type ColorValue, Image, StyleSheet, Text, View } from "react-native";
|
||||
import { colors, fonts } from "@/lib/theme";
|
||||
|
||||
// Tiny hand-drawn glyphs so the app has no icon dependency.
|
||||
|
||||
export function MenuIcon({ color = colors.ink }: { color?: ColorValue }) {
|
||||
return (
|
||||
<View style={styles.menu}>
|
||||
<View style={[styles.bar, { width: 18, backgroundColor: color }]} />
|
||||
<View style={[styles.bar, { width: 12, backgroundColor: color }]} />
|
||||
<View style={[styles.bar, { width: 15, backgroundColor: color }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpIcon({
|
||||
color = colors.darkText,
|
||||
}: {
|
||||
color?: ColorValue;
|
||||
}) {
|
||||
return <Text style={[styles.glyph, { color }]}>↑</Text>;
|
||||
}
|
||||
|
||||
export function StopIcon({ color = colors.darkText }: { color?: ColorValue }) {
|
||||
return <View style={[styles.stop, { backgroundColor: color }]} />;
|
||||
}
|
||||
|
||||
export function Logomark({ size = 56 }: { size?: number }) {
|
||||
return (
|
||||
<Image
|
||||
source={require("../../assets/logomark.png")}
|
||||
style={{ width: size, height: size }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop's task status dot: solid for live states, a hollow ring otherwise.
|
||||
export function Dot({
|
||||
color = colors.inkMute,
|
||||
size = 8,
|
||||
hollow = false,
|
||||
}: {
|
||||
color?: ColorValue;
|
||||
size?: number;
|
||||
hollow?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: hollow ? "transparent" : color,
|
||||
borderWidth: hollow ? 1.5 : 0,
|
||||
borderColor: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BellIcon({ color = colors.ink }: { color?: ColorValue }) {
|
||||
return (
|
||||
<View style={styles.bell}>
|
||||
<View style={[styles.bellBody, { borderColor: color }]} />
|
||||
<View style={[styles.bellClapper, { backgroundColor: color }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function SteeringIcon({ color = colors.ink }: { color?: ColorValue }) {
|
||||
return (
|
||||
<View style={styles.steer}>
|
||||
<View style={[styles.steerRing, { borderColor: color }]} />
|
||||
<View style={[styles.steerHub, { backgroundColor: color }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardsIcon({ color = colors.ink }: { color?: ColorValue }) {
|
||||
return (
|
||||
<View style={styles.cards}>
|
||||
<View style={[styles.cardBack, { borderColor: color }]} />
|
||||
<View style={[styles.cardFront, { borderColor: color }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// A rubbish bin: handle, lid, then the body with two slats.
|
||||
export function BinIcon({ color = colors.ink }: { color?: ColorValue }) {
|
||||
return (
|
||||
<View style={styles.bin}>
|
||||
<View style={[styles.binHandle, { borderColor: color }]} />
|
||||
<View style={[styles.binLid, { backgroundColor: color }]} />
|
||||
<View style={[styles.binBody, { borderColor: color }]}>
|
||||
<View style={[styles.binSlat, { backgroundColor: color }]} />
|
||||
<View style={[styles.binSlat, { backgroundColor: color }]} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bin: { alignItems: "center" },
|
||||
binHandle: {
|
||||
width: 10,
|
||||
height: 5,
|
||||
borderWidth: 2.5,
|
||||
borderBottomWidth: 0,
|
||||
borderTopLeftRadius: 3,
|
||||
borderTopRightRadius: 3,
|
||||
},
|
||||
binLid: { width: 24, height: 2.5, borderRadius: 2 },
|
||||
binBody: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
marginTop: 2,
|
||||
borderWidth: 2.5,
|
||||
borderTopWidth: 0,
|
||||
borderBottomLeftRadius: 5,
|
||||
borderBottomRightRadius: 5,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-evenly",
|
||||
paddingTop: 2,
|
||||
},
|
||||
binSlat: { width: 2.5, height: 9, borderRadius: 2 },
|
||||
menu: { gap: 3.5, alignItems: "flex-start" },
|
||||
bar: { height: 2, borderRadius: 1 },
|
||||
glyph: { fontSize: 20, fontFamily: fonts.sansBold, marginTop: -1 },
|
||||
stop: { width: 14, height: 14, borderRadius: 3 },
|
||||
cards: { width: 22, height: 20 },
|
||||
cardBack: {
|
||||
position: "absolute",
|
||||
left: 6,
|
||||
top: 0,
|
||||
width: 12,
|
||||
height: 16,
|
||||
borderWidth: 1.8,
|
||||
borderRadius: 3,
|
||||
transform: [{ rotate: "12deg" }],
|
||||
},
|
||||
cardFront: {
|
||||
position: "absolute",
|
||||
left: 1,
|
||||
top: 2,
|
||||
width: 12,
|
||||
height: 16,
|
||||
borderWidth: 1.8,
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.bg,
|
||||
transform: [{ rotate: "-8deg" }],
|
||||
},
|
||||
steer: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
steerRing: { width: 18, height: 18, borderRadius: 9, borderWidth: 1.8 },
|
||||
steerHub: { position: "absolute", width: 6, height: 6, borderRadius: 3 },
|
||||
bell: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
bellBody: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderWidth: 1.8,
|
||||
borderTopLeftRadius: 7,
|
||||
borderTopRightRadius: 7,
|
||||
borderBottomLeftRadius: 2,
|
||||
borderBottomRightRadius: 2,
|
||||
},
|
||||
bellClapper: { width: 5, height: 2.5, borderRadius: 2, marginTop: 1 },
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { isSafeExternalUrl } from "@posthog/shared";
|
||||
import { type MarkedToken, marked, type Token, type Tokens } from "marked";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import {
|
||||
type ColorValue,
|
||||
Linking,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { colors, fonts } from "@/lib/theme";
|
||||
|
||||
interface MarkdownProps {
|
||||
text: string;
|
||||
color?: ColorValue;
|
||||
}
|
||||
|
||||
function openLink(href: string): void {
|
||||
if (isSafeExternalUrl(href)) Linking.openURL(href).catch(() => {});
|
||||
}
|
||||
|
||||
function renderInline(
|
||||
tokens: Token[] | undefined,
|
||||
color: ColorValue,
|
||||
): ReactNode[] {
|
||||
return (tokens ?? []).map((raw, index) => {
|
||||
const token = raw as MarkedToken;
|
||||
const key = String(index);
|
||||
switch (token.type) {
|
||||
case "strong":
|
||||
return (
|
||||
<Text key={key} style={{ fontFamily: fonts.sansSemi, color }}>
|
||||
{renderInline(token.tokens, color)}
|
||||
</Text>
|
||||
);
|
||||
case "em":
|
||||
return (
|
||||
<Text key={key} style={{ fontFamily: fonts.sansItalic, color }}>
|
||||
{renderInline(token.tokens, color)}
|
||||
</Text>
|
||||
);
|
||||
case "del":
|
||||
return (
|
||||
<Text key={key} style={styles.strike}>
|
||||
{renderInline(token.tokens, color)}
|
||||
</Text>
|
||||
);
|
||||
case "codespan":
|
||||
return (
|
||||
<Text key={key} style={styles.inlineCode}>
|
||||
{token.text}
|
||||
</Text>
|
||||
);
|
||||
case "link":
|
||||
return (
|
||||
<Text
|
||||
key={key}
|
||||
style={styles.link}
|
||||
onPress={() => openLink(token.href)}
|
||||
>
|
||||
{renderInline(token.tokens, colors.accent)}
|
||||
</Text>
|
||||
);
|
||||
case "br":
|
||||
return "\n";
|
||||
case "text":
|
||||
return token.tokens ? (
|
||||
<Text key={key}>{renderInline(token.tokens, color)}</Text>
|
||||
) : (
|
||||
token.text
|
||||
);
|
||||
default:
|
||||
return "text" in token ? String(token.text) : "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Width from the longest cell, so columns line up without measuring.
|
||||
function columnWidths(rows: string[][]): number[] {
|
||||
const count = Math.max(...rows.map((row) => row.length));
|
||||
return Array.from({ length: count }, (_, column) => {
|
||||
const longest = Math.max(...rows.map((row) => row[column]?.length ?? 0));
|
||||
return Math.min(240, Math.max(72, longest * 7.5 + 24));
|
||||
});
|
||||
}
|
||||
|
||||
function Table({ token, color }: { token: Tokens.Table; color: ColorValue }) {
|
||||
const rows = [token.header, ...token.rows];
|
||||
const widths = columnWidths(rows.map((row) => row.map((cell) => cell.text)));
|
||||
return (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.table}>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<View
|
||||
key={String(rowIndex)}
|
||||
style={[
|
||||
styles.tableRow,
|
||||
rowIndex > 0 && styles.tableRowDivided,
|
||||
rowIndex === 0 && styles.tableHeader,
|
||||
]}
|
||||
>
|
||||
{widths.map((width, column) => (
|
||||
<Text
|
||||
key={String(column)}
|
||||
style={[
|
||||
styles.tableCell,
|
||||
rowIndex === 0 && styles.tableHeaderText,
|
||||
{ width, color },
|
||||
]}
|
||||
selectable
|
||||
>
|
||||
{renderInline(row[column]?.tokens, color)}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function List({ token, color }: { token: Tokens.List; color: ColorValue }) {
|
||||
const start = typeof token.start === "number" ? token.start : 1;
|
||||
return (
|
||||
<View style={styles.list}>
|
||||
{token.items.map((item, index) => (
|
||||
<View key={String(index)} style={styles.bulletRow}>
|
||||
<Text style={[styles.bulletMark, { color }]}>
|
||||
{token.ordered ? `${start + index}.` : "•"}
|
||||
</Text>
|
||||
<View style={styles.bulletText}>
|
||||
{renderBlocks(item.tokens, color)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBlocks(
|
||||
tokens: Token[] | undefined,
|
||||
color: ColorValue,
|
||||
): ReactNode[] {
|
||||
return (tokens ?? []).map((raw, index) => {
|
||||
const token = raw as MarkedToken;
|
||||
const key = `${index}-${token.type}`;
|
||||
switch (token.type) {
|
||||
case "code":
|
||||
return (
|
||||
<View key={key} style={styles.codeBlock}>
|
||||
{token.lang ? (
|
||||
<Text style={styles.codeLang}>{token.lang}</Text>
|
||||
) : null}
|
||||
<Text style={styles.codeText} selectable>
|
||||
{token.text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
case "heading":
|
||||
return (
|
||||
<Text
|
||||
key={key}
|
||||
style={[
|
||||
styles.heading,
|
||||
{
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: token.depth <= 2 ? 19 : 16,
|
||||
color,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{renderInline(token.tokens, color)}
|
||||
</Text>
|
||||
);
|
||||
case "list":
|
||||
return <List key={key} token={token} color={color} />;
|
||||
case "blockquote":
|
||||
return (
|
||||
<View key={key} style={styles.quote}>
|
||||
{renderBlocks(token.tokens, colors.inkSoft)}
|
||||
</View>
|
||||
);
|
||||
case "table":
|
||||
return <Table key={key} token={token} color={color} />;
|
||||
case "hr":
|
||||
return <View key={key} style={styles.rule} />;
|
||||
// Tight list items hold their text as a bare text token.
|
||||
case "paragraph":
|
||||
case "text":
|
||||
return (
|
||||
<Text key={key} style={[styles.body, { color }]} selectable>
|
||||
{renderInline(token.tokens, color)}
|
||||
</Text>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function Markdown({ text, color = colors.ink }: MarkdownProps) {
|
||||
const tokens = useMemo(() => marked.lexer(text), [text]);
|
||||
return <View style={styles.root}>{renderBlocks(tokens, color)}</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { gap: 8 },
|
||||
body: { fontFamily: fonts.sans, fontSize: 16, lineHeight: 24 },
|
||||
heading: { fontFamily: fonts.sansSemi, lineHeight: 26, marginTop: 4 },
|
||||
list: { gap: 4 },
|
||||
bulletRow: { flexDirection: "row", gap: 8, paddingLeft: 4 },
|
||||
bulletMark: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
minWidth: 14,
|
||||
},
|
||||
bulletText: { flex: 1, gap: 4 },
|
||||
quote: {
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: colors.line,
|
||||
paddingLeft: 12,
|
||||
gap: 8,
|
||||
},
|
||||
rule: { height: StyleSheet.hairlineWidth, backgroundColor: colors.line },
|
||||
codeBlock: {
|
||||
backgroundColor: colors.code,
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
gap: 4,
|
||||
},
|
||||
codeLang: {
|
||||
fontFamily: fonts.monoMedium,
|
||||
fontSize: 10,
|
||||
color: colors.inkMute,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
codeText: {
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
color: colors.ink,
|
||||
},
|
||||
inlineCode: {
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 14,
|
||||
backgroundColor: colors.code,
|
||||
color: colors.ink,
|
||||
},
|
||||
strike: { textDecorationLine: "line-through" },
|
||||
link: { color: colors.accent, textDecorationLine: "underline" },
|
||||
table: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.line,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
},
|
||||
tableRow: { flexDirection: "row" },
|
||||
tableRowDivided: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.line,
|
||||
},
|
||||
tableHeader: { backgroundColor: colors.fill },
|
||||
tableCell: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
tableHeaderText: { fontFamily: fonts.sansSemi },
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { resolveResultResourceUri } from "@posthog/core/mcp-apps/schemas";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
useColorScheme,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import WebView from "react-native-webview";
|
||||
import { GlassCircleButton } from "@/components/Glass";
|
||||
import { listMcpTools, readMcpResource } from "@/lib/mcp/client";
|
||||
import { sandboxProxyHtml } from "@/lib/mcp/sandboxProxyHtml";
|
||||
import {
|
||||
type Phase,
|
||||
type UiResource,
|
||||
useAppBridge,
|
||||
} from "@/lib/mcp/useAppBridge";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
import type { Block } from "@/lib/transcript";
|
||||
|
||||
const MIN_HEIGHT = 180;
|
||||
const MAX_HEIGHT = 520;
|
||||
|
||||
// Does this tool result come with an MCP UI app to render it?
|
||||
export function appResourceUri(output: unknown): string | undefined {
|
||||
return resolveResultResourceUri(output);
|
||||
}
|
||||
|
||||
function useUiResource(uri: string) {
|
||||
return useQuery<UiResource | null>({
|
||||
queryKey: ["mcp", "ui-resource", uri],
|
||||
queryFn: async () => {
|
||||
const read = await readMcpResource(uri);
|
||||
const content = read.contents.find((item) => item.uri === uri) as
|
||||
| {
|
||||
text?: unknown;
|
||||
mimeType?: unknown;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
const html = typeof content?.text === "string" ? content.text : null;
|
||||
const mime =
|
||||
typeof content?.mimeType === "string" ? content.mimeType : "";
|
||||
if (!html || !mime.startsWith("text/html")) return null;
|
||||
const ui = (content?._meta?.ui ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
uri,
|
||||
html,
|
||||
csp: ui.csp as UiResource["csp"],
|
||||
permissions: ui.permissions as UiResource["permissions"],
|
||||
};
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
// Hosts an MCP UI app (a chart, a table) for a finished tool call, inline in
|
||||
// the transcript with a fullscreen mode.
|
||||
export function McpAppHost({ block }: { block: Block & { kind: "tool" } }) {
|
||||
const uri = appResourceUri(block.output) ?? "";
|
||||
const resource = useUiResource(uri);
|
||||
const tools = useQuery({ queryKey: ["mcp", "tools"], queryFn: listMcpTools });
|
||||
const scheme = useColorScheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const webViewRef = useRef<WebView | null>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [displayMode, setDisplayMode] = useState<McpUiDisplayMode>("inline");
|
||||
const [height, setHeight] = useState(MIN_HEIGHT);
|
||||
|
||||
const { onWebViewMessage } = useAppBridge({
|
||||
webViewRef,
|
||||
resource: resource.data,
|
||||
tool: tools.data?.find((tool) => tool.name === (block.toolName ?? "exec")),
|
||||
toolInput: block.input,
|
||||
toolResult: block.output as CallToolResult | null,
|
||||
dark: scheme === "dark",
|
||||
displayMode,
|
||||
width,
|
||||
insets,
|
||||
onPhase: setPhase,
|
||||
onHeight: setHeight,
|
||||
onDisplayMode: setDisplayMode,
|
||||
});
|
||||
|
||||
if (resource.isError || phase === "error") {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.muted}>Couldn't load this view.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const inlineHeight = Math.min(Math.max(height, MIN_HEIGHT), MAX_HEIGHT);
|
||||
const app = (
|
||||
<View
|
||||
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
|
||||
style={[
|
||||
styles.card,
|
||||
{ height: displayMode === "fullscreen" ? "100%" : inlineHeight },
|
||||
]}
|
||||
>
|
||||
{resource.data ? (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
originWhitelist={["*"]}
|
||||
source={{ html: sandboxProxyHtml }}
|
||||
onLoadStart={() => setPhase("loading")}
|
||||
onMessage={(event) => onWebViewMessage(event.nativeEvent.data)}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled
|
||||
setSupportMultipleWindows={false}
|
||||
mixedContentMode="always"
|
||||
style={styles.webview}
|
||||
/>
|
||||
) : null}
|
||||
{phase !== "initialized" ? (
|
||||
<View pointerEvents="none" style={styles.spinner}>
|
||||
<ActivityIndicator color={colors.inkMute} />
|
||||
</View>
|
||||
) : null}
|
||||
{displayMode === "inline" ? (
|
||||
<Pressable
|
||||
onPress={() => setDisplayMode("fullscreen")}
|
||||
hitSlop={8}
|
||||
style={styles.expand}
|
||||
>
|
||||
<Text style={styles.expandText}>⤢</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (displayMode === "fullscreen") {
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
animationType="slide"
|
||||
presentationStyle="fullScreen"
|
||||
onRequestClose={() => setDisplayMode("inline")}
|
||||
>
|
||||
<View style={[styles.full, { paddingTop: insets.top + 6 }]}>
|
||||
<View style={styles.fullHeader}>
|
||||
<GlassCircleButton onPress={() => setDisplayMode("inline")}>
|
||||
<Text style={styles.close}>×</Text>
|
||||
</GlassCircleButton>
|
||||
<Text style={styles.fullTitle} numberOfLines={1}>
|
||||
{block.title}
|
||||
</Text>
|
||||
<View style={{ width: 46 }} />
|
||||
</View>
|
||||
<View style={[styles.fullBody, { paddingBottom: insets.bottom }]}>
|
||||
{app}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.card,
|
||||
overflow: "hidden",
|
||||
},
|
||||
webview: { flex: 1, backgroundColor: "transparent" },
|
||||
spinner: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
muted: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 14,
|
||||
color: colors.inkMute,
|
||||
padding: 16,
|
||||
},
|
||||
expand: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: colors.fill,
|
||||
},
|
||||
expandText: { fontSize: 16, color: colors.inkSoft },
|
||||
full: { flex: 1, backgroundColor: colors.bg },
|
||||
fullHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
fullTitle: {
|
||||
fontFamily: fonts.sansSemi,
|
||||
fontSize: 17,
|
||||
color: colors.ink,
|
||||
flex: 1,
|
||||
textAlign: "center",
|
||||
},
|
||||
close: { fontSize: 26, lineHeight: 28, color: colors.ink, marginTop: -2 },
|
||||
fullBody: { flex: 1, paddingHorizontal: 12 },
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeInUp,
|
||||
FadeOutUp,
|
||||
LinearTransition,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Glass } from "@/components/Glass";
|
||||
import { useOnline } from "@/lib/network";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
|
||||
const BACK_ONLINE_MS = 2000;
|
||||
|
||||
export function OfflinePill() {
|
||||
const online = useOnline((s) => s.online);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [backOnline, setBackOnline] = useState(false);
|
||||
const wasOffline = useRef(false);
|
||||
|
||||
// Confirms the reconnect for a moment before the pill leaves.
|
||||
useEffect(() => {
|
||||
if (!online) {
|
||||
wasOffline.current = true;
|
||||
setBackOnline(false);
|
||||
return;
|
||||
}
|
||||
if (!wasOffline.current) return;
|
||||
wasOffline.current = false;
|
||||
setBackOnline(true);
|
||||
const timer = setTimeout(() => setBackOnline(false), BACK_ONLINE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [online]);
|
||||
|
||||
if (online && !backOnline) return null;
|
||||
return (
|
||||
<View style={[styles.root, { top: insets.top + 4 }]} pointerEvents="none">
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(220)}
|
||||
exiting={FadeOutUp.duration(220)}
|
||||
layout={LinearTransition.duration(220)}
|
||||
>
|
||||
<Glass style={styles.pill}>
|
||||
<View
|
||||
style={[
|
||||
styles.dot,
|
||||
{ backgroundColor: online ? colors.ok : colors.danger },
|
||||
]}
|
||||
/>
|
||||
<Animated.Text
|
||||
key={online ? "online" : "offline"}
|
||||
entering={FadeIn.duration(200)}
|
||||
style={styles.text}
|
||||
>
|
||||
{online ? "Back online" : "Offline"}
|
||||
</Animated.Text>
|
||||
</Glass>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { position: "absolute", left: 0, right: 0, alignItems: "center" },
|
||||
pill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
dot: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 4,
|
||||
},
|
||||
text: { fontFamily: fonts.sansMedium, fontSize: 13, color: colors.inkSoft },
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
deriveHeadline,
|
||||
formatSignalReportSummaryMarkdown,
|
||||
parseConventionalCommitTitle,
|
||||
splitReportSummary,
|
||||
} from "@posthog/core/inbox/reportPresentation";
|
||||
import { formatRelativeAge } from "@posthog/shared";
|
||||
import type {
|
||||
SignalReport,
|
||||
SignalReportPriority,
|
||||
SuggestedReviewersArtefact,
|
||||
} from "@posthog/shared/domain-types";
|
||||
import {
|
||||
type ColorValue,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { useReportArtefacts, useReportSignals } from "@/lib/reports";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
|
||||
const PRIORITY: Record<
|
||||
SignalReportPriority,
|
||||
{ bg: ColorValue; fg: ColorValue }
|
||||
> = {
|
||||
P0: { bg: "rgba(255,71,77,0.16)", fg: colors.danger },
|
||||
P1: { bg: "rgba(255,92,28,0.16)", fg: colors.accent },
|
||||
P2: { bg: "rgba(20,144,232,0.14)", fg: "#106FB2" },
|
||||
P3: { bg: colors.fill, fg: colors.inkSoft },
|
||||
P4: { bg: colors.fill, fg: colors.inkSoft },
|
||||
};
|
||||
|
||||
function sourceLabel(products: string[] | undefined): string {
|
||||
const first = products?.[0];
|
||||
if (!first) return "Signal";
|
||||
return first
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function parsedTitle(report: SignalReport): {
|
||||
tag: string | null;
|
||||
text: string;
|
||||
} {
|
||||
const raw =
|
||||
report.title ?? deriveHeadline(report.summary) ?? "Untitled report";
|
||||
const parsed = parseConventionalCommitTitle(raw);
|
||||
if (!parsed) return { tag: null, text: raw };
|
||||
const scope = parsed.scope ? `(${parsed.scope})` : "";
|
||||
return { tag: `${parsed.type}${scope}`, text: parsed.description };
|
||||
}
|
||||
|
||||
export function PriorityChip({ priority }: { priority: SignalReportPriority }) {
|
||||
const tone = PRIORITY[priority];
|
||||
return (
|
||||
<View style={[styles.priority, { backgroundColor: tone.bg }]}>
|
||||
<Text style={[styles.priorityText, { color: tone.fg }]}>{priority}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// The face of a triage card: source, priority, title, lede and the meta line.
|
||||
// The top card also previews the first pieces of evidence.
|
||||
export function ReportSummary({
|
||||
report,
|
||||
withEvidence,
|
||||
hideLede,
|
||||
}: {
|
||||
report: SignalReport;
|
||||
withEvidence?: boolean;
|
||||
hideLede?: boolean;
|
||||
}) {
|
||||
const title = parsedTitle(report);
|
||||
const lede = hideLede ? null : deriveHeadline(report.summary);
|
||||
return (
|
||||
<View style={styles.summary}>
|
||||
<Text style={styles.source}>{sourceLabel(report.source_products)}</Text>
|
||||
<View style={styles.titleRow}>
|
||||
{report.priority ? <PriorityChip priority={report.priority} /> : null}
|
||||
{title.tag ? <Text style={styles.tag}>{title.tag}</Text> : null}
|
||||
</View>
|
||||
<Text style={styles.title}>{title.text}</Text>
|
||||
{lede && lede !== title.text ? (
|
||||
<Text style={styles.lede} numberOfLines={5}>
|
||||
{lede}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={styles.meta}>
|
||||
{report.signal_count} signal{report.signal_count === 1 ? "" : "s"} ·
|
||||
First seen {formatRelativeAge(report.created_at)} · Updated{" "}
|
||||
{formatRelativeAge(report.updated_at)}
|
||||
</Text>
|
||||
{withEvidence ? <EvidencePreview reportId={report.id} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidencePreview({ reportId }: { reportId: string }) {
|
||||
const signals = useReportSignals(reportId);
|
||||
const evidence = (signals.data?.signals ?? []).slice(0, 2);
|
||||
if (evidence.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.preview}>
|
||||
<Text style={styles.previewTitle}>Evidence</Text>
|
||||
{evidence.map((signal) => (
|
||||
<View key={signal.signal_id} style={styles.evidence}>
|
||||
<Text style={styles.evidenceMeta}>
|
||||
{sourceLabel([signal.source_product])} ·{" "}
|
||||
{formatRelativeAge(signal.timestamp)}
|
||||
</Text>
|
||||
<Text style={styles.evidenceBody} numberOfLines={4}>
|
||||
{signal.content.trim()}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// The expanded card: full summary sections, evidence and reviewers.
|
||||
export function ReportDetail({ report }: { report: SignalReport }) {
|
||||
const signals = useReportSignals(report.id);
|
||||
const artefacts = useReportArtefacts(report.id);
|
||||
const summary = splitReportSummary(
|
||||
report.summary ? formatSignalReportSummaryMarkdown(report.summary) : "",
|
||||
);
|
||||
const reviewers = artefacts.data?.results.find(
|
||||
(artefact): artefact is SuggestedReviewersArtefact =>
|
||||
artefact.type === "suggested_reviewers",
|
||||
);
|
||||
const evidence = signals.data?.signals ?? [];
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.detail}
|
||||
contentContainerStyle={styles.detailContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<ReportSummary report={report} hideLede />
|
||||
{report.already_addressed ? (
|
||||
<View style={styles.notice}>
|
||||
<Text style={styles.noticeTitle}>Likely already fixed</Text>
|
||||
<Text style={styles.noticeBody}>
|
||||
The evidence suggests this was addressed. Skim the summary and
|
||||
dismiss it if you agree.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{summary.lede ? <Markdown text={summary.lede} /> : null}
|
||||
{summary.sections.map((section) => (
|
||||
<View key={section.title} style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<Markdown text={section.body} />
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Evidence{evidence.length ? ` · ${evidence.length}` : ""}
|
||||
</Text>
|
||||
{signals.isLoading ? <Text style={styles.muted}>Loading</Text> : null}
|
||||
{evidence.map((signal) => (
|
||||
<View key={signal.signal_id} style={styles.evidence}>
|
||||
<Text style={styles.evidenceMeta}>
|
||||
{sourceLabel([signal.source_product])} ·{" "}
|
||||
{formatRelativeAge(signal.timestamp)}
|
||||
</Text>
|
||||
<Text style={styles.evidenceBody} numberOfLines={8}>
|
||||
{signal.content.trim()}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{reviewers && reviewers.content.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Reviewers</Text>
|
||||
{reviewers.content.map((reviewer) => (
|
||||
<Text
|
||||
key={reviewer.github_login ?? reviewer.github_name ?? "?"}
|
||||
style={styles.reviewer}
|
||||
>
|
||||
{reviewer.github_name ?? reviewer.github_login ?? "Unknown"}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardButton({
|
||||
label,
|
||||
onPress,
|
||||
primary,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
primary?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
primary && styles.buttonPrimary,
|
||||
(pressed || disabled) && { opacity: 0.5 },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.buttonText, primary && styles.buttonTextPrimary]}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
summary: { gap: 10 },
|
||||
source: {
|
||||
fontFamily: fonts.sansMedium,
|
||||
fontSize: 13,
|
||||
color: colors.inkMute,
|
||||
},
|
||||
titleRow: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
priority: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6 },
|
||||
priorityText: { fontFamily: fonts.sansBold, fontSize: 12 },
|
||||
tag: {
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 12,
|
||||
color: colors.inkSoft,
|
||||
backgroundColor: colors.code,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
},
|
||||
title: {
|
||||
fontFamily: fonts.sansBold,
|
||||
fontSize: 22,
|
||||
lineHeight: 28,
|
||||
color: colors.ink,
|
||||
},
|
||||
lede: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 16,
|
||||
lineHeight: 23,
|
||||
color: colors.inkSoft,
|
||||
},
|
||||
meta: { fontFamily: fonts.sans, fontSize: 13, color: colors.inkMute },
|
||||
preview: { gap: 8, marginTop: 6 },
|
||||
previewTitle: {
|
||||
fontFamily: fonts.sansSemi,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1.2,
|
||||
textTransform: "uppercase",
|
||||
color: colors.inkMute,
|
||||
},
|
||||
detail: { flex: 1 },
|
||||
detailContent: { gap: 18, paddingBottom: 120 },
|
||||
notice: {
|
||||
backgroundColor: "rgba(20,144,232,0.10)",
|
||||
borderRadius: radius.card,
|
||||
padding: 14,
|
||||
gap: 4,
|
||||
},
|
||||
noticeTitle: { fontFamily: fonts.sansSemi, fontSize: 15, color: colors.ink },
|
||||
noticeBody: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: colors.inkSoft,
|
||||
},
|
||||
section: { gap: 8 },
|
||||
sectionTitle: { fontFamily: fonts.sansSemi, fontSize: 17, color: colors.ink },
|
||||
muted: { fontFamily: fonts.sans, fontSize: 14, color: colors.inkMute },
|
||||
evidence: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
gap: 6,
|
||||
},
|
||||
evidenceMeta: {
|
||||
fontFamily: fonts.sansMedium,
|
||||
fontSize: 12,
|
||||
color: colors.inkMute,
|
||||
},
|
||||
evidenceBody: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: colors.ink,
|
||||
},
|
||||
reviewer: { fontFamily: fonts.sans, fontSize: 15, color: colors.ink },
|
||||
button: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.fill,
|
||||
},
|
||||
buttonPrimary: { backgroundColor: colors.accent },
|
||||
buttonText: { fontFamily: fonts.sansSemi, fontSize: 15, color: colors.ink },
|
||||
buttonTextPrimary: { color: "#FFFFFF" },
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { GlassCircleButton } from "@/components/Glass";
|
||||
import { colors, fonts } from "@/lib/theme";
|
||||
|
||||
// Header for pages inside a form sheet: X on the root page, back chevron deeper.
|
||||
export function SheetHeader({
|
||||
title,
|
||||
back,
|
||||
}: {
|
||||
title: string;
|
||||
back?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<GlassCircleButton size={44} onPress={() => router.back()}>
|
||||
<Text style={styles.glyph}>{back ? "‹" : "×"}</Text>
|
||||
</GlassCircleButton>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<View style={{ width: 44 }} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 4,
|
||||
},
|
||||
glyph: { fontSize: 28, lineHeight: 30, color: colors.ink, marginTop: -3 },
|
||||
title: { fontFamily: fonts.sansSemi, fontSize: 17, color: colors.ink },
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Host, Image } from "@expo/ui/swift-ui";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
|
||||
function Radio({ on }: { on: boolean }) {
|
||||
return (
|
||||
<View style={[styles.radio, on && styles.radioOn]}>
|
||||
{on ? <View style={styles.radioDot} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// One row in a sheet card: a menu row (value + chevron), a radio option, or a checkmarked option.
|
||||
export function SheetRow({
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
radio,
|
||||
checked,
|
||||
trailing,
|
||||
first,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
onPress?: () => void;
|
||||
radio?: boolean;
|
||||
checked?: boolean;
|
||||
trailing?: string;
|
||||
first?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={!onPress}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
!first && styles.rowDivider,
|
||||
pressed && { opacity: 0.5 },
|
||||
]}
|
||||
>
|
||||
{radio !== undefined ? <Radio on={radio} /> : null}
|
||||
<Text style={[styles.label, radio && styles.labelOn]} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
{value ? <Text style={styles.value}>{value}</Text> : null}
|
||||
{trailing ? <Text style={styles.trailing}>{trailing}</Text> : null}
|
||||
{checked ? (
|
||||
<Host matchContents>
|
||||
<Image systemName="checkmark" size={16} color={colors.accent} />
|
||||
</Host>
|
||||
) : null}
|
||||
{onPress && radio === undefined && checked === undefined ? (
|
||||
<Text style={styles.chevron}>›</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export const sheetStyles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: colors.bg },
|
||||
list: { padding: 18, paddingBottom: 40, gap: 14 },
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: radius.card,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
section: { gap: 8 },
|
||||
sectionTitle: {
|
||||
fontFamily: fonts.sansSemi,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1.2,
|
||||
textTransform: "uppercase",
|
||||
color: colors.inkMute,
|
||||
marginLeft: 6,
|
||||
},
|
||||
footnote: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 12,
|
||||
color: colors.inkMute,
|
||||
marginLeft: 6,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingVertical: 13,
|
||||
},
|
||||
rowDivider: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.line,
|
||||
},
|
||||
label: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 16,
|
||||
color: colors.ink,
|
||||
flexShrink: 1,
|
||||
},
|
||||
labelOn: { fontFamily: fonts.sansSemi },
|
||||
value: { fontFamily: fonts.sans, fontSize: 16, color: colors.inkMute },
|
||||
trailing: { fontFamily: fonts.sans, fontSize: 14, color: colors.inkMute },
|
||||
chevron: {
|
||||
fontSize: 20,
|
||||
lineHeight: 22,
|
||||
color: colors.inkMute,
|
||||
marginLeft: 2,
|
||||
},
|
||||
radio: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.line,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
radioOn: { borderColor: colors.accent, backgroundColor: colors.accent },
|
||||
radioDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { StyleSheet, Text, type TextStyle, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { colors } from "@/lib/theme";
|
||||
|
||||
// Soft edges come from a few strips of the highlight at rising opacity.
|
||||
const STRIP_ALPHAS = [0.35, 0.7, 1, 0.7, 0.35];
|
||||
const STRIP_WIDTH = 24;
|
||||
const BAND = STRIP_WIDTH * STRIP_ALPHAS.length;
|
||||
|
||||
// Muted text with a bright band sweeping across it, the "thinking" shimmer.
|
||||
// No gradient module needed: a clipped copy of the text rides inside each strip.
|
||||
export function ShimmerText({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: string;
|
||||
style?: TextStyle;
|
||||
}) {
|
||||
const [width, setWidth] = useState(0);
|
||||
const x = useSharedValue(-BAND);
|
||||
|
||||
useEffect(() => {
|
||||
if (width === 0) return;
|
||||
x.value = -BAND;
|
||||
x.value = withRepeat(
|
||||
withTiming(width, { duration: 1400, easing: Easing.linear }),
|
||||
-1,
|
||||
);
|
||||
}, [width, x]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.root}
|
||||
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
|
||||
>
|
||||
<Text style={[style, styles.base]}>{children}</Text>
|
||||
{STRIP_ALPHAS.map((alpha, index) => (
|
||||
<Strip
|
||||
key={String(index)}
|
||||
x={x}
|
||||
index={index}
|
||||
alpha={alpha}
|
||||
width={width}
|
||||
>
|
||||
<Text style={[style, styles.highlight]} numberOfLines={1}>
|
||||
{children}
|
||||
</Text>
|
||||
</Strip>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Strip({
|
||||
x,
|
||||
index,
|
||||
alpha,
|
||||
width,
|
||||
children,
|
||||
}: {
|
||||
x: { value: number };
|
||||
index: number;
|
||||
alpha: number;
|
||||
width: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const offset = index * STRIP_WIDTH;
|
||||
const window = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: x.value + offset }],
|
||||
}));
|
||||
// The copy keeps the full text width so the strip clips rather than wraps it.
|
||||
const content = useAnimatedStyle(() => ({
|
||||
width,
|
||||
transform: [{ translateX: -(x.value + offset) }],
|
||||
}));
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[styles.strip, { opacity: alpha }, window]}
|
||||
>
|
||||
<Animated.View style={content}>{children}</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { alignSelf: "flex-start", overflow: "hidden" },
|
||||
base: { color: colors.inkSoft },
|
||||
highlight: { color: colors.inkMute },
|
||||
strip: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: STRIP_WIDTH,
|
||||
overflow: "hidden",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Button, ContextMenu, Host, RNHostView } from "@expo/ui/swift-ui";
|
||||
import { formatRelativeAge } from "@posthog/shared";
|
||||
import type { Task } from "@posthog/shared/domain-types";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useUpdateTask } from "@/lib/queries";
|
||||
import { colors, fonts } from "@/lib/theme";
|
||||
|
||||
function isRunning(task: Task): boolean {
|
||||
const status = task.latest_run?.status;
|
||||
return status === "queued" || status === "in_progress";
|
||||
}
|
||||
|
||||
export function activityAt(task: Task): string {
|
||||
return task.last_activity_at ?? task.updated_at;
|
||||
}
|
||||
|
||||
function taskTitle(task: Task): string {
|
||||
return (
|
||||
task.title || task.description_preview || task.description || "Untitled"
|
||||
);
|
||||
}
|
||||
|
||||
export function ListRow({
|
||||
title,
|
||||
label,
|
||||
time,
|
||||
running = false,
|
||||
onPress,
|
||||
}: {
|
||||
title: string;
|
||||
label?: string;
|
||||
time: string;
|
||||
running?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const age = formatRelativeAge(time);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [styles.row, pressed && { opacity: 0.5 }]}
|
||||
>
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.time}>{label ? `${label} · ${age}` : age}</Text>
|
||||
</View>
|
||||
{running ? (
|
||||
<ActivityIndicator size="small" color={colors.inkSoft} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTaskActions(task: Task | undefined) {
|
||||
const updateTask = useUpdateTask();
|
||||
const rename = (): void => {
|
||||
if (!task) return;
|
||||
Alert.prompt(
|
||||
"Rename",
|
||||
"Enter a new name",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "OK",
|
||||
onPress: (title?: string) => {
|
||||
if (title?.trim()) {
|
||||
updateTask.mutate({ id: task.id, title: title.trim() });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
"plain-text",
|
||||
taskTitle(task),
|
||||
);
|
||||
};
|
||||
const setArchived = (archived: boolean, onSuccess?: () => void): void => {
|
||||
if (task) updateTask.mutate({ id: task.id, archived }, { onSuccess });
|
||||
};
|
||||
return { rename, setArchived };
|
||||
}
|
||||
|
||||
export function TaskRow({
|
||||
task,
|
||||
label,
|
||||
archived = false,
|
||||
onPress,
|
||||
}: {
|
||||
task: Task;
|
||||
label?: string;
|
||||
archived?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { rename, setArchived } = useTaskActions(task);
|
||||
return (
|
||||
<Host matchContents={{ vertical: true }}>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Items>
|
||||
<Button label="Rename" systemImage="pencil" onPress={rename} />
|
||||
{archived ? (
|
||||
<Button
|
||||
label="Restore"
|
||||
systemImage="arrow.uturn.backward"
|
||||
onPress={() => setArchived(false)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
label="Archive"
|
||||
systemImage="archivebox"
|
||||
onPress={() => setArchived(true)}
|
||||
/>
|
||||
)}
|
||||
</ContextMenu.Items>
|
||||
<ContextMenu.Trigger>
|
||||
<RNHostView matchContents>
|
||||
<ListRow
|
||||
title={taskTitle(task)}
|
||||
label={label}
|
||||
time={activityAt(task)}
|
||||
running={isRunning(task)}
|
||||
onPress={onPress}
|
||||
/>
|
||||
</RNHostView>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
const SKELETON_WIDTHS = ["72%", "54%", "86%", "62%", "78%"] as const;
|
||||
|
||||
export function RowSkeletons({
|
||||
count = SKELETON_WIDTHS.length,
|
||||
}: {
|
||||
count?: number;
|
||||
}) {
|
||||
const opacity = useSharedValue(1);
|
||||
useEffect(() => {
|
||||
opacity.value = withRepeat(
|
||||
withTiming(0.4, { duration: 800, easing: Easing.inOut(Easing.ease) }),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
}, [opacity]);
|
||||
const pulse = useAnimatedStyle(() => ({ opacity: opacity.value }));
|
||||
return (
|
||||
<Animated.View style={pulse}>
|
||||
{SKELETON_WIDTHS.slice(0, count).map((width) => (
|
||||
<View key={width} style={styles.row}>
|
||||
<View style={styles.body}>
|
||||
<View style={[styles.skeletonTitle, { width }]} />
|
||||
<View style={styles.skeletonTime} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
minHeight: 64,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
body: { flex: 1, minWidth: 0, gap: 3 },
|
||||
title: {
|
||||
fontFamily: fonts.sansMedium,
|
||||
fontSize: 17,
|
||||
lineHeight: 23,
|
||||
color: colors.ink,
|
||||
},
|
||||
time: { fontFamily: fonts.sans, fontSize: 14, color: colors.inkSoft },
|
||||
skeletonTitle: {
|
||||
height: 17,
|
||||
marginVertical: 3,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.fill,
|
||||
},
|
||||
skeletonTime: {
|
||||
width: 56,
|
||||
height: 14,
|
||||
marginVertical: 2,
|
||||
borderRadius: 5,
|
||||
backgroundColor: colors.fill,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,682 @@
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
type ColorValue,
|
||||
Image,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { Glass } from "@/components/Glass";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { appResourceUri, McpAppHost } from "@/components/McpAppHost";
|
||||
import { ShimmerText } from "@/components/ShimmerText";
|
||||
import type { TaskSession } from "@/lib/session";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
import type { Block, PermissionRequest, ToolStatus } from "@/lib/transcript";
|
||||
|
||||
interface TranscriptProps {
|
||||
onPermission: (toolCallId: string, optionId: string) => void;
|
||||
}
|
||||
|
||||
type ToolBlock = Block & { kind: "tool" };
|
||||
type ThoughtBlock = Block & { kind: "thought" };
|
||||
|
||||
interface Activity {
|
||||
kind: "activity";
|
||||
id: string;
|
||||
items: Array<ToolBlock | ThoughtBlock>;
|
||||
children: Map<string, ToolBlock[]>;
|
||||
permissions: TaskSession["permissions"];
|
||||
toolCount: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
type Row =
|
||||
| { kind: "block"; block: Block }
|
||||
| { kind: "app"; block: ToolBlock }
|
||||
| Activity;
|
||||
|
||||
// Consecutive tool calls and thoughts collapse into one activity row, so the
|
||||
// transcript reads as messages with a "Working" line between them.
|
||||
function arrange(
|
||||
blocks: Block[],
|
||||
permissions: TaskSession["permissions"],
|
||||
): Row[] {
|
||||
const children = new Map<string, ToolBlock[]>();
|
||||
for (const block of blocks) {
|
||||
if (block.kind === "tool" && block.parentId) {
|
||||
const list = children.get(block.parentId) ?? [];
|
||||
list.push(block);
|
||||
children.set(block.parentId, list);
|
||||
}
|
||||
}
|
||||
const rows: Row[] = [];
|
||||
let open: Activity | null = null;
|
||||
for (const block of blocks) {
|
||||
if (block.kind === "tool" && block.parentId) continue;
|
||||
if (block.kind === "tool" && appResourceUri(block.output)) {
|
||||
open = null;
|
||||
rows.push({ kind: "app", block });
|
||||
continue;
|
||||
}
|
||||
if (block.kind === "tool" || block.kind === "thought") {
|
||||
if (!open) {
|
||||
open = {
|
||||
kind: "activity",
|
||||
id: `activity-${block.id}`,
|
||||
items: [],
|
||||
children,
|
||||
permissions,
|
||||
toolCount: 0,
|
||||
start: block.at,
|
||||
end: block.at,
|
||||
};
|
||||
rows.push(open);
|
||||
}
|
||||
open.items.push(block);
|
||||
const latest = block.kind === "tool" ? block.updatedAt : block.at;
|
||||
open.end = Math.max(open.end, latest);
|
||||
if (block.kind === "tool") {
|
||||
open.toolCount += 1 + (children.get(block.id)?.length ?? 0);
|
||||
for (const child of children.get(block.id) ?? []) {
|
||||
open.end = Math.max(open.end, child.updatedAt);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
open = null;
|
||||
rows.push({ kind: "block", block });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export type TranscriptRow =
|
||||
| { kind: "block"; id: string; block: Block }
|
||||
| { kind: "app"; id: string; block: ToolBlock }
|
||||
| (Activity & { active: boolean })
|
||||
| { kind: "permission"; id: string; request: PermissionRequest }
|
||||
| { kind: "status"; id: string; label: string };
|
||||
|
||||
// Flattens a session into list rows: messages, collapsed activity, any
|
||||
// permission cards with no tool block yet, and the trailing status line.
|
||||
export function buildTranscriptRows(
|
||||
session: TaskSession,
|
||||
workingLabel = "Working",
|
||||
): TranscriptRow[] {
|
||||
const arranged = arrange(session.blocks, session.permissions);
|
||||
const last = arranged[arranged.length - 1];
|
||||
const rows: TranscriptRow[] = arranged.map((row) =>
|
||||
row.kind === "activity"
|
||||
? { ...row, active: session.turnActive && row === last }
|
||||
: row.kind === "app"
|
||||
? { kind: "app", id: `app-${row.block.id}`, block: row.block }
|
||||
: { kind: "block", id: row.block.id, block: row.block },
|
||||
);
|
||||
for (const request of Object.values(session.permissions)) {
|
||||
if (!session.blocks.some((block) => block.id === request.toolCallId)) {
|
||||
rows.push({ kind: "permission", id: request.requestId, request });
|
||||
}
|
||||
}
|
||||
if ((session.turnActive || !session.connected) && last?.kind !== "activity") {
|
||||
rows.push({ kind: "status", id: "status", label: workingLabel });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const TranscriptRowView = memo(
|
||||
function TranscriptRowView({
|
||||
row,
|
||||
onPermission,
|
||||
}: {
|
||||
row: TranscriptRow;
|
||||
onPermission: TranscriptProps["onPermission"];
|
||||
}) {
|
||||
switch (row.kind) {
|
||||
case "block":
|
||||
return <BlockView block={row.block} />;
|
||||
case "app":
|
||||
return <McpAppHost block={row.block} />;
|
||||
case "activity":
|
||||
return (
|
||||
<ActivityRow
|
||||
activity={row}
|
||||
active={row.active}
|
||||
permissions={row.permissions}
|
||||
onPermission={onPermission}
|
||||
/>
|
||||
);
|
||||
case "permission":
|
||||
return (
|
||||
<PermissionCard request={row.request} onPermission={onPermission} />
|
||||
);
|
||||
case "status":
|
||||
return <StatusLine label={row.label} active />;
|
||||
}
|
||||
},
|
||||
// Rows are rebuilt on every fold; only re-render when their content moved.
|
||||
(prev, next) => {
|
||||
if (prev.row.kind !== next.row.kind || prev.row.id !== next.row.id) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(prev.row.kind === "block" && next.row.kind === "block") ||
|
||||
(prev.row.kind === "app" && next.row.kind === "app")
|
||||
) {
|
||||
return prev.row.block === next.row.block;
|
||||
}
|
||||
if (prev.row.kind === "activity" && next.row.kind === "activity") {
|
||||
return (
|
||||
prev.row.active === next.row.active &&
|
||||
prev.row.end === next.row.end &&
|
||||
prev.row.items.length === next.row.items.length &&
|
||||
prev.row.permissions === next.row.permissions
|
||||
);
|
||||
}
|
||||
if (prev.row.kind === "status" && next.row.kind === "status") {
|
||||
return prev.row.label === next.row.label;
|
||||
}
|
||||
return prev.row.kind === "permission" && next.row.kind === "permission"
|
||||
? prev.row.request === next.row.request
|
||||
: false;
|
||||
},
|
||||
);
|
||||
|
||||
function BlockView({ block }: { block: Block }) {
|
||||
switch (block.kind) {
|
||||
case "user":
|
||||
return (
|
||||
<View style={styles.userRow}>
|
||||
{block.images?.length ? (
|
||||
<View style={styles.userImages}>
|
||||
{block.images.map((uri) => (
|
||||
<Image key={uri} source={{ uri }} style={styles.userImage} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{block.text ? (
|
||||
<View style={styles.userBubble}>
|
||||
<Text style={styles.userText} selectable>
|
||||
{block.text}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
case "agent":
|
||||
return (
|
||||
<View style={styles.agentRow}>
|
||||
<Markdown text={block.text} />
|
||||
</View>
|
||||
);
|
||||
case "plan":
|
||||
return (
|
||||
<View style={styles.plan}>
|
||||
<Text style={styles.planTitle}>Plan</Text>
|
||||
{block.entries.map((entry, index) => (
|
||||
<View key={`${index}-${entry.content}`} style={styles.planRow}>
|
||||
<Text style={styles.planMark}>
|
||||
{entry.status === "completed"
|
||||
? "✓"
|
||||
: entry.status === "in_progress"
|
||||
? "›"
|
||||
: "○"}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.planText,
|
||||
entry.status === "completed" && { color: colors.inkMute },
|
||||
]}
|
||||
>
|
||||
{entry.content}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.max(0, Math.round(ms / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
|
||||
function useNow(enabled: boolean): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [enabled]);
|
||||
return now;
|
||||
}
|
||||
|
||||
// Fun stand-ins for "Working", rotated while a turn runs.
|
||||
const BUSY_WORDS = [
|
||||
"Working",
|
||||
"Thinking",
|
||||
"Noodling",
|
||||
"Rummaging",
|
||||
"Sniffing around",
|
||||
"Cooking",
|
||||
"Poking at it",
|
||||
"Hedgehogging",
|
||||
];
|
||||
|
||||
function useBusyWord(enabled: boolean): string {
|
||||
const [index, setIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const timer = setInterval(
|
||||
() => setIndex((value) => (value + 1) % BUSY_WORDS.length),
|
||||
5000,
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, [enabled]);
|
||||
return BUSY_WORDS[index] ?? "Working";
|
||||
}
|
||||
|
||||
export function StatusLine({
|
||||
label,
|
||||
active,
|
||||
detail,
|
||||
open,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
detail?: string;
|
||||
open?: boolean;
|
||||
onPress?: () => void;
|
||||
}) {
|
||||
const rotate = active && label === "Working";
|
||||
const word = useBusyWord(rotate);
|
||||
const shown = rotate ? word : label;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={!onPress}
|
||||
style={({ pressed }) => [styles.status, pressed && { opacity: 0.6 }]}
|
||||
>
|
||||
{onPress ? (
|
||||
<Text style={[styles.chevron, open && styles.chevronOpen]}>›</Text>
|
||||
) : null}
|
||||
{active ? (
|
||||
<Animated.View key={shown} entering={FadeIn.duration(260)}>
|
||||
<ShimmerText style={styles.statusLabel}>{shown}</ShimmerText>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Text style={styles.statusLabel}>{shown}</Text>
|
||||
)}
|
||||
{active ? (
|
||||
<ActivityIndicator size="small" color={colors.inkMute} />
|
||||
) : null}
|
||||
{detail ? <Text style={styles.statusDetail}>{detail}</Text> : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityRow({
|
||||
activity,
|
||||
active,
|
||||
permissions,
|
||||
onPermission,
|
||||
}: {
|
||||
activity: Activity;
|
||||
active: boolean;
|
||||
permissions: TaskSession["permissions"];
|
||||
onPermission: TranscriptProps["onPermission"];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const now = useNow(active);
|
||||
const elapsed = formatDuration(
|
||||
(active ? now : activity.end) - activity.start,
|
||||
);
|
||||
const calls = `${activity.toolCount} tool call${
|
||||
activity.toolCount === 1 ? "" : "s"
|
||||
}`;
|
||||
const pending = activity.items
|
||||
.filter((item): item is ToolBlock => item.kind === "tool")
|
||||
.flatMap((tool) => [tool, ...(activity.children.get(tool.id) ?? [])])
|
||||
.map((tool) => permissions[tool.id])
|
||||
.filter((request): request is PermissionRequest => !!request);
|
||||
const latestThought = active
|
||||
? [...activity.items].reverse().find((item) => item.kind === "thought")
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<View style={styles.activity}>
|
||||
<StatusLine
|
||||
label={active ? "Working" : "Worked"}
|
||||
active={active}
|
||||
detail={`· ${elapsed}${activity.toolCount > 0 ? ` · ${calls}` : ""}`}
|
||||
open={open}
|
||||
onPress={() => setOpen((value) => !value)}
|
||||
/>
|
||||
{!open && latestThought ? (
|
||||
<Text style={styles.thinkingPeek} numberOfLines={1}>
|
||||
{latestThought.text.trim()}
|
||||
</Text>
|
||||
) : null}
|
||||
{open ? (
|
||||
<View style={styles.activityBody}>
|
||||
{activity.items.map((item) =>
|
||||
item.kind === "thought" ? (
|
||||
<Text key={item.id} style={styles.thoughtText} selectable>
|
||||
{item.text.trim()}
|
||||
</Text>
|
||||
) : (
|
||||
<View key={item.id}>
|
||||
<ToolRow block={item} />
|
||||
{(activity.children.get(item.id) ?? []).map((child) => (
|
||||
<ToolRow key={child.id} block={child} nested />
|
||||
))}
|
||||
</View>
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{pending.map((request) => (
|
||||
<PermissionCard
|
||||
key={request.requestId}
|
||||
request={request}
|
||||
onPermission={onPermission}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(status: ToolStatus): ColorValue {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return colors.ok;
|
||||
case "failed":
|
||||
return colors.danger;
|
||||
case "running":
|
||||
return colors.accent;
|
||||
default:
|
||||
return colors.inkMute;
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeInput(input?: Record<string, unknown>): string {
|
||||
if (!input) return "";
|
||||
const preferred = [
|
||||
"command",
|
||||
"file_path",
|
||||
"path",
|
||||
"pattern",
|
||||
"query",
|
||||
"url",
|
||||
"description",
|
||||
"prompt",
|
||||
];
|
||||
for (const key of preferred) {
|
||||
const value = input[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
const first = Object.values(input).find((value) => typeof value === "string");
|
||||
return typeof first === "string" ? first : "";
|
||||
}
|
||||
|
||||
function pretty(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function ToolRow({ block, nested }: { block: ToolBlock; nested?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const summary = summarizeInput(block.input);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => setOpen((value) => !value)}
|
||||
style={[styles.tool, nested && styles.toolNested]}
|
||||
>
|
||||
<View style={styles.toolHeader}>
|
||||
{block.status === "running" ? (
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.toolDot,
|
||||
{ backgroundColor: statusColor(block.status) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.toolTitle} numberOfLines={1}>
|
||||
{block.toolName ?? block.title}
|
||||
</Text>
|
||||
{summary ? (
|
||||
<Text style={styles.toolSummary} numberOfLines={1}>
|
||||
{summary}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{open ? (
|
||||
<View style={styles.toolDetail}>
|
||||
{block.input ? (
|
||||
<>
|
||||
<Text style={styles.detailLabel}>Input</Text>
|
||||
<Text style={styles.detailText} selectable>
|
||||
{pretty(block.input)}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
{block.output !== undefined ? (
|
||||
<>
|
||||
<Text style={styles.detailLabel}>Output</Text>
|
||||
<Text style={styles.detailText} selectable numberOfLines={60}>
|
||||
{pretty(block.output)}
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionCard({
|
||||
request,
|
||||
onPermission,
|
||||
}: {
|
||||
request: PermissionRequest;
|
||||
onPermission: TranscriptProps["onPermission"];
|
||||
}) {
|
||||
const summary = summarizeInput(request.input);
|
||||
const answered = !!request.chosenOptionId;
|
||||
return (
|
||||
<Glass style={styles.permission} tint="rgba(217,117,91,0.08)">
|
||||
<Text style={styles.permissionTitle}>{request.title}</Text>
|
||||
{summary ? (
|
||||
<Text style={styles.detailText} numberOfLines={4}>
|
||||
{summary}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.optionRow}>
|
||||
{request.options.map((option) => {
|
||||
const primary = option.kind.startsWith("allow");
|
||||
const chosen = request.chosenOptionId === option.optionId;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.optionId}
|
||||
disabled={answered}
|
||||
onPress={() => onPermission(request.toolCallId, option.optionId)}
|
||||
style={({ pressed }) => [
|
||||
styles.option,
|
||||
primary ? styles.optionPrimary : styles.optionSecondary,
|
||||
(pressed || (answered && !chosen)) && { opacity: 0.5 },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
primary
|
||||
? styles.optionPrimaryText
|
||||
: styles.optionSecondaryText
|
||||
}
|
||||
>
|
||||
{option.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Glass>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
userRow: {
|
||||
alignItems: "flex-end",
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
userImages: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-end",
|
||||
gap: 6,
|
||||
maxWidth: "84%",
|
||||
marginBottom: 6,
|
||||
},
|
||||
userImage: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.fill,
|
||||
},
|
||||
userBubble: {
|
||||
maxWidth: "84%",
|
||||
backgroundColor: colors.dark,
|
||||
borderRadius: radius.bubble,
|
||||
borderBottomRightRadius: 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
userText: {
|
||||
color: colors.darkText,
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
},
|
||||
agentRow: { paddingRight: 8 },
|
||||
status: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
chevron: {
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 20,
|
||||
lineHeight: 22,
|
||||
color: colors.inkMute,
|
||||
width: 12,
|
||||
textAlign: "center",
|
||||
},
|
||||
chevronOpen: { transform: [{ rotate: "90deg" }] },
|
||||
statusLabel: { fontFamily: fonts.sans, fontSize: 16, color: colors.ink },
|
||||
statusDetail: { fontFamily: fonts.sans, fontSize: 15, color: colors.inkMute },
|
||||
activity: { gap: 6 },
|
||||
thinkingPeek: {
|
||||
color: colors.inkMute,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
paddingLeft: 20,
|
||||
fontFamily: fonts.sansItalic,
|
||||
},
|
||||
activityBody: { gap: 6, paddingLeft: 8 },
|
||||
thoughtText: {
|
||||
color: colors.inkMute,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontFamily: fonts.sansItalic,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
tool: { paddingVertical: 6 },
|
||||
toolNested: { paddingLeft: 18 },
|
||||
toolHeader: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||
toolDot: { width: 7, height: 7, borderRadius: 4 },
|
||||
toolTitle: { fontFamily: fonts.monoMedium, fontSize: 12, color: colors.ink },
|
||||
toolSummary: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 12,
|
||||
color: colors.inkSoft,
|
||||
},
|
||||
toolDetail: { marginTop: 8, gap: 4, paddingLeft: 15 },
|
||||
detailLabel: {
|
||||
fontFamily: fonts.monoMedium,
|
||||
fontSize: 10,
|
||||
letterSpacing: 1,
|
||||
textTransform: "uppercase",
|
||||
color: colors.inkMute,
|
||||
},
|
||||
detailText: {
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
color: colors.inkSoft,
|
||||
},
|
||||
permission: {
|
||||
borderRadius: radius.card,
|
||||
padding: 14,
|
||||
gap: 8,
|
||||
overflow: "hidden",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(217,117,91,0.35)",
|
||||
},
|
||||
permissionTitle: {
|
||||
fontSize: 15,
|
||||
fontFamily: fonts.sansSemi,
|
||||
color: colors.ink,
|
||||
},
|
||||
optionRow: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 4 },
|
||||
option: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 9,
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
optionPrimary: { backgroundColor: colors.dark },
|
||||
optionSecondary: { backgroundColor: colors.fill },
|
||||
optionPrimaryText: {
|
||||
color: colors.darkText,
|
||||
fontFamily: fonts.sansSemi,
|
||||
fontSize: 14,
|
||||
},
|
||||
optionSecondaryText: {
|
||||
color: colors.ink,
|
||||
fontFamily: fonts.sansMedium,
|
||||
fontSize: 14,
|
||||
},
|
||||
plan: { gap: 8, paddingVertical: 4 },
|
||||
planTitle: { fontSize: 15, fontFamily: fonts.sansSemi, color: colors.ink },
|
||||
planRow: { flexDirection: "row", gap: 8, alignItems: "flex-start" },
|
||||
planMark: {
|
||||
fontFamily: fonts.mono,
|
||||
color: colors.inkMute,
|
||||
width: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
planText: {
|
||||
flex: 1,
|
||||
fontFamily: fonts.sans,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: colors.ink,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import type { SignalReport } from "@posthog/shared/domain-types";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Image,
|
||||
StyleSheet,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Glass, GlassCircleButton } from "@/components/Glass";
|
||||
import { BinIcon } from "@/components/Icons";
|
||||
import {
|
||||
CardButton,
|
||||
ReportDetail,
|
||||
ReportSummary,
|
||||
} from "@/components/ReportCard";
|
||||
import { colors, fonts, radius } from "@/lib/theme";
|
||||
|
||||
const SWIPE_THRESHOLD = 110;
|
||||
const FLY_OUT = 600;
|
||||
const CARD_RADIUS = 28;
|
||||
// Room below the top card for the stack to peek out.
|
||||
const STACK_PEEK = 28;
|
||||
|
||||
interface TriageDeckProps {
|
||||
reports: SignalReport[];
|
||||
onDismiss: (report: SignalReport) => void;
|
||||
onStart: (report: SignalReport) => void;
|
||||
starting?: boolean;
|
||||
// Height of the screen header the deck sits under; the frame starts below it.
|
||||
headerHeight: number;
|
||||
}
|
||||
|
||||
// Tinder-style stack: swipe left to dismiss, right to start a task, or open
|
||||
// the card in place to read the whole report before deciding.
|
||||
export function TriageDeck({
|
||||
reports,
|
||||
onDismiss,
|
||||
onStart,
|
||||
starting,
|
||||
headerHeight,
|
||||
}: TriageDeckProps) {
|
||||
const { width, height } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [frame, setFrame] = useState({ x: 0, y: 0, w: 0, h: 0 });
|
||||
const x = useSharedValue(0);
|
||||
const y = useSharedValue(0);
|
||||
const grow = useSharedValue(0);
|
||||
const top = reports[0];
|
||||
const rest = reports.slice(1, 3);
|
||||
|
||||
const flyOut = (direction: 1 | -1, report: SignalReport): void => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
x.value = withTiming(direction * FLY_OUT, { duration: 220 }, (done) => {
|
||||
if (done) {
|
||||
runOnJS(direction === 1 ? onStart : onDismiss)(report);
|
||||
x.value = 0;
|
||||
y.value = 0;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const swipe = (direction: 1 | -1): void => {
|
||||
if (top) flyOut(direction, top);
|
||||
};
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.enabled(!expanded && !!top)
|
||||
.activeOffsetX([-12, 12])
|
||||
.failOffsetY([-16, 16])
|
||||
.onChange((event) => {
|
||||
x.value = event.translationX;
|
||||
y.value = event.translationY * 0.25;
|
||||
})
|
||||
.onEnd((event) => {
|
||||
if (event.translationX > SWIPE_THRESHOLD) {
|
||||
runOnJS(swipe)(1);
|
||||
} else if (event.translationX < -SWIPE_THRESHOLD) {
|
||||
runOnJS(swipe)(-1);
|
||||
} else {
|
||||
x.value = withSpring(0, { damping: 18, stiffness: 220 });
|
||||
y.value = withSpring(0, { damping: 18, stiffness: 220 });
|
||||
}
|
||||
});
|
||||
|
||||
const toggleExpanded = (): void => {
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
// Critically damped and quick, like a UIKit sheet, no bounce.
|
||||
grow.value = withSpring(next ? 1 : 0, {
|
||||
duration: 380,
|
||||
dampingRatio: 1,
|
||||
overshootClamping: true,
|
||||
});
|
||||
};
|
||||
|
||||
// The top card lives in a fixed frame; expanding animates that frame out to
|
||||
// the full screen so the report opens from where the card sits.
|
||||
const topStyle = useAnimatedStyle(() => ({
|
||||
position: "absolute",
|
||||
left: interpolate(grow.value, [0, 1], [frame.x, 0]),
|
||||
top: interpolate(grow.value, [0, 1], [frame.y, 0]),
|
||||
width: interpolate(grow.value, [0, 1], [frame.w, width]),
|
||||
height: interpolate(grow.value, [0, 1], [frame.h - STACK_PEEK, height]),
|
||||
borderRadius: interpolate(grow.value, [0, 1], [CARD_RADIUS, 0]),
|
||||
transform: [
|
||||
{ translateX: x.value },
|
||||
{ translateY: y.value },
|
||||
{ rotate: `${interpolate(x.value, [-300, 0, 300], [-10, 0, 10])}deg` },
|
||||
],
|
||||
}));
|
||||
const startHint = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(x.value, [20, SWIPE_THRESHOLD], [0, 1], "clamp"),
|
||||
}));
|
||||
const dismissHint = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(x.value, [-SWIPE_THRESHOLD, -20], [1, 0], "clamp"),
|
||||
}));
|
||||
// The overlay clips itself, since the card no longer does (its shadow needs
|
||||
// to escape).
|
||||
const detailStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(grow.value, [0.6, 1], [0, 1], "clamp"),
|
||||
borderRadius: interpolate(grow.value, [0, 1], [CARD_RADIUS, 0]),
|
||||
}));
|
||||
const faceStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(grow.value, [0, 0.4], [1, 0], "clamp"),
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={styles.root} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(280)}
|
||||
style={[styles.frame, { marginTop: headerHeight + 8 }]}
|
||||
onLayout={(event) => {
|
||||
const {
|
||||
x: fx,
|
||||
y: fy,
|
||||
width: fw,
|
||||
height: fh,
|
||||
} = event.nativeEvent.layout;
|
||||
setFrame({ x: fx, y: fy, w: fw, h: fh });
|
||||
}}
|
||||
>
|
||||
{rest.map((report, index) => (
|
||||
<View
|
||||
key={report.id}
|
||||
style={[
|
||||
styles.card,
|
||||
styles.behind,
|
||||
{
|
||||
bottom: STACK_PEEK,
|
||||
zIndex: -(index + 1),
|
||||
transform: [
|
||||
{ scale: 1 - (index + 1) * 0.05 },
|
||||
{ translateY: (index + 1) * (STACK_PEEK / 2) + 6 },
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ReportSummary report={report} />
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
{top && frame.w > 0 ? (
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(280)}
|
||||
style={[styles.card, styles.topCard, topStyle]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[styles.face, faceStyle]}
|
||||
pointerEvents={expanded ? "none" : "auto"}
|
||||
>
|
||||
<ReportSummary report={top} withEvidence />
|
||||
<View style={styles.actions}>
|
||||
<CardButton label="Open report" onPress={toggleExpanded} />
|
||||
</View>
|
||||
<Animated.View style={[styles.hint, styles.hintStart, startHint]}>
|
||||
<Text style={styles.hintText}>Start</Text>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[styles.hint, styles.hintDismiss, dismissHint]}
|
||||
>
|
||||
<Text style={styles.hintText}>Dismiss</Text>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
{expanded ? (
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
styles.detail,
|
||||
{ paddingTop: insets.top + 64 },
|
||||
detailStyle,
|
||||
]}
|
||||
>
|
||||
<ReportDetail report={top} />
|
||||
<View style={[styles.detailHeader, { top: insets.top + 6 }]}>
|
||||
<GlassCircleButton
|
||||
onPress={toggleExpanded}
|
||||
tint={colors.glassTint}
|
||||
>
|
||||
<Text style={styles.close}>×</Text>
|
||||
</GlassCircleButton>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.detailActions,
|
||||
{ paddingBottom: insets.bottom + 12 },
|
||||
]}
|
||||
>
|
||||
<Glass
|
||||
style={styles.detailActionsGlass}
|
||||
tint={colors.glassTint}
|
||||
>
|
||||
<CardButton
|
||||
label="Dismiss"
|
||||
onPress={() => {
|
||||
toggleExpanded();
|
||||
setTimeout(() => flyOut(-1, top), 250);
|
||||
}}
|
||||
/>
|
||||
<CardButton
|
||||
label="Start task"
|
||||
primary
|
||||
disabled={starting}
|
||||
onPress={() => {
|
||||
toggleExpanded();
|
||||
setTimeout(() => flyOut(1, top), 250);
|
||||
}}
|
||||
/>
|
||||
</Glass>
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
) : null}
|
||||
|
||||
{/* Glass never renders when mounted inside a layout animation, so the
|
||||
footer stays out of the deck's entering fade. */}
|
||||
{!expanded ? (
|
||||
<View style={[styles.footer, { paddingBottom: insets.bottom + 8 }]}>
|
||||
<View style={styles.verdict}>
|
||||
<GlassCircleButton
|
||||
size={64}
|
||||
disabled={!top}
|
||||
onPress={() => swipe(-1)}
|
||||
>
|
||||
<BinIcon />
|
||||
</GlassCircleButton>
|
||||
<Text style={styles.caption}>Dismiss</Text>
|
||||
</View>
|
||||
<Text style={styles.counter}>{reports.length} to triage</Text>
|
||||
<View style={styles.verdict}>
|
||||
<GlassCircleButton
|
||||
size={64}
|
||||
disabled={!top || starting}
|
||||
onPress={() => swipe(1)}
|
||||
>
|
||||
<View style={styles.goClip}>
|
||||
<Image
|
||||
source={require("../../assets/hedgehog/idle-strip.png")}
|
||||
style={styles.goSprite}
|
||||
/>
|
||||
</View>
|
||||
</GlassCircleButton>
|
||||
<Text style={styles.caption}>Start task</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 },
|
||||
frame: { flex: 1, marginHorizontal: 16, marginBottom: 12 },
|
||||
card: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: CARD_RADIUS,
|
||||
padding: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.16,
|
||||
shadowRadius: 28,
|
||||
shadowOffset: { width: 0, height: 14 },
|
||||
},
|
||||
behind: { position: "absolute", top: 0, left: 0, right: 0 },
|
||||
topCard: { justifyContent: "space-between" },
|
||||
face: { flex: 1, justifyContent: "space-between" },
|
||||
actions: { flexDirection: "row", gap: 8, marginTop: 16 },
|
||||
hint: {
|
||||
position: "absolute",
|
||||
top: 18,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
hintStart: { left: 0, backgroundColor: colors.ok },
|
||||
hintDismiss: { right: 0, backgroundColor: colors.inkMute },
|
||||
hintText: { fontFamily: fonts.sansBold, fontSize: 13, color: "#FFFFFF" },
|
||||
detail: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingHorizontal: 20,
|
||||
overflow: "hidden",
|
||||
},
|
||||
detailHeader: { position: "absolute", left: 16 },
|
||||
detailActions: { position: "absolute", left: 16, right: 16, bottom: 0 },
|
||||
detailActionsGlass: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
padding: 10,
|
||||
borderRadius: radius.pill,
|
||||
overflow: "hidden",
|
||||
},
|
||||
close: { fontSize: 26, lineHeight: 28, color: colors.ink, marginTop: -2 },
|
||||
footer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
counter: { fontFamily: fonts.sans, fontSize: 14, color: colors.inkMute },
|
||||
verdict: { alignItems: "center", gap: 6 },
|
||||
caption: {
|
||||
fontFamily: fonts.sansMedium,
|
||||
fontSize: 12,
|
||||
color: colors.inkMute,
|
||||
},
|
||||
goClip: { width: 44, height: 44, overflow: "hidden", marginTop: -6 },
|
||||
// First idle frame of the 25-frame strip, scaled to sit in the circle.
|
||||
goSprite: { width: 44 * 25, height: 44 },
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import Animated, {
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { colors } from "@/lib/theme";
|
||||
|
||||
export const SAMPLE_MS = 80;
|
||||
// Ring of recent levels, larger than the widest strip in steps.
|
||||
export const RING = 96;
|
||||
const BAR = 3;
|
||||
const STEP = BAR + 4;
|
||||
const HEIGHT = 22;
|
||||
// Share of the strip over which bars grow in at the right and shrink out at the left.
|
||||
const FADE_IN = 0.06;
|
||||
const FADE_OUT = 0.08;
|
||||
|
||||
function envelope(position: number): number {
|
||||
"worklet";
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(1, (1 - position) / FADE_IN, position / FADE_OUT),
|
||||
);
|
||||
}
|
||||
|
||||
// Slot k shows the sample k steps behind the head; slot -1 is the one entering.
|
||||
function Bar({
|
||||
slot,
|
||||
levels,
|
||||
head,
|
||||
width,
|
||||
}: {
|
||||
slot: number;
|
||||
levels: SharedValue<number[]>;
|
||||
head: SharedValue<number>;
|
||||
width: SharedValue<number>;
|
||||
}) {
|
||||
const style = useAnimatedStyle(() => {
|
||||
const id = Math.floor(head.value) - slot;
|
||||
const level = id >= 0 ? levels.value[id % RING] : -1;
|
||||
const x = width.value - STEP / 2 - (head.value - id) * STEP;
|
||||
const size = level < 0 ? 0 : level < 0.08 ? BAR : BAR + level * HEIGHT;
|
||||
const height = width.value > 0 ? size * envelope(x / width.value) : 0;
|
||||
const barWidth = Math.min(BAR, height);
|
||||
return {
|
||||
height,
|
||||
width: barWidth,
|
||||
transform: [
|
||||
{ translateX: x - barWidth / 2 },
|
||||
{ translateY: (HEIGHT + BAR - height) / 2 },
|
||||
],
|
||||
};
|
||||
});
|
||||
return <Animated.View style={[styles.bar, style]} />;
|
||||
}
|
||||
|
||||
// Renders once; every frame reads levels and the gliding head on the UI thread.
|
||||
export function Waveform({
|
||||
levels,
|
||||
head,
|
||||
}: {
|
||||
levels: SharedValue<number[]>;
|
||||
head: SharedValue<number>;
|
||||
}) {
|
||||
const width = useSharedValue(0);
|
||||
const [slots, setSlots] = useState(0);
|
||||
return (
|
||||
<View
|
||||
style={styles.root}
|
||||
onLayout={(event) => {
|
||||
width.value = event.nativeEvent.layout.width;
|
||||
setSlots(Math.ceil(event.nativeEvent.layout.width / STEP) + 1);
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: slots + 1 }, (_, index) => (
|
||||
<Bar
|
||||
key={String(index)}
|
||||
slot={index - 1}
|
||||
levels={levels}
|
||||
head={head}
|
||||
width={width}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, height: HEIGHT + BAR, overflow: "hidden" },
|
||||
bar: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
borderRadius: BAR / 2,
|
||||
backgroundColor: colors.ink,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// Local stack by default. Use your LAN IP instead of localhost on a real phone.
|
||||
export const POSTHOG_HOST = "http://localhost:8010";
|
||||
|
||||
// Prefilled on the login screen in development builds only.
|
||||
export const DEV_EMAIL = "test@posthog.com";
|
||||
export const DEV_PASSWORD = "12345678";
|
||||
|
||||
// "owner/repo". Null uses the most recently used repository on the project.
|
||||
export const DEFAULT_REPOSITORY: string | null = null;
|
||||
|
||||
export const DEFAULT_MODEL = "claude-opus-4-8";
|
||||
|
||||
// PostHog's MCP server per region; local is the services/mcp dev server.
|
||||
export const MCP_HOSTS = {
|
||||
local: "http://localhost:8787/mcp",
|
||||
us: "https://mcp.posthog.com/mcp",
|
||||
eu: "https://mcp-eu.posthog.com/mcp",
|
||||
} as const;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { sessionIdentity, useAuth } from "@/lib/auth";
|
||||
import {
|
||||
CACHE_MAX_AGE,
|
||||
clearAccountCache,
|
||||
persistQueryCache,
|
||||
} from "@/lib/cache";
|
||||
import { resetClient } from "@/lib/client";
|
||||
import { useComposer } from "@/lib/composer";
|
||||
import { resetEngine } from "@/lib/engine";
|
||||
import { resetMcpClient } from "@/lib/mcp/client";
|
||||
import { useRepo } from "@/lib/repo";
|
||||
import { resetUnstartedReportTasks, useSeenReports } from "@/lib/reports";
|
||||
import { useSessions } from "@/lib/session";
|
||||
|
||||
let stopPersisting: (() => void) | undefined;
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
// Saved queries must outlive their screens to stay readable offline.
|
||||
queries: { retry: 1, refetchOnWindowFocus: false, gcTime: CACHE_MAX_AGE },
|
||||
},
|
||||
});
|
||||
stopPersisting?.();
|
||||
const { session } = useAuth.getState();
|
||||
stopPersisting = session ? persistQueryCache(client, session) : undefined;
|
||||
return client;
|
||||
}
|
||||
|
||||
let queryClient = createQueryClient();
|
||||
|
||||
useAuth.subscribe((state, previous) => {
|
||||
if (sessionIdentity(state) === sessionIdentity(previous)) return;
|
||||
if (previous.session && !state.session) clearAccountCache(previous.session);
|
||||
queryClient.clear();
|
||||
queryClient = createQueryClient();
|
||||
useSessions.getState().reset();
|
||||
resetEngine();
|
||||
resetClient();
|
||||
resetMcpClient();
|
||||
useComposer.getState().reset();
|
||||
useRepo.setState({ repository: undefined });
|
||||
useSeenReports.setState({ seen: new Set(), hydrated: false });
|
||||
resetUnstartedReportTasks();
|
||||
});
|
||||
|
||||
export function getAccountQueryClient(): QueryClient {
|
||||
return queryClient;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { TaskActivityItem } from "@posthog/core/canvas/taskActivity";
|
||||
import { toTaskActivityItems } from "@posthog/core/canvas/taskActivity";
|
||||
import { formatRelativeAge, getRelativeDateGroup } from "@posthog/shared";
|
||||
import type { TaskActivityPage } from "@posthog/shared/domain-types";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { getClient } from "@/lib/client";
|
||||
|
||||
export const activityKey = ["activity"] as const;
|
||||
|
||||
export function useActivity() {
|
||||
const session = useAuth((s) => s.session);
|
||||
return useQuery<TaskActivityPage>({
|
||||
queryKey: activityKey,
|
||||
queryFn: () => getClient().getTaskActivity(),
|
||||
enabled: !!session,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkActivityRead() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (item: TaskActivityItem) =>
|
||||
getClient().markTaskActivityRead([
|
||||
{
|
||||
task_id: item.taskId,
|
||||
seen_before: item.activityAt,
|
||||
activity_id: item.id,
|
||||
},
|
||||
]),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: activityKey }),
|
||||
});
|
||||
}
|
||||
|
||||
export type AgentIcon = "chat" | "check" | "question" | null;
|
||||
|
||||
export interface ActivityRow {
|
||||
item: TaskActivityItem;
|
||||
icon: AgentIcon;
|
||||
initials: string | null;
|
||||
metadata: string;
|
||||
space: string | null;
|
||||
}
|
||||
|
||||
function authorName(item: TaskActivityItem): string {
|
||||
const author = item.author;
|
||||
if (!author) return "Someone";
|
||||
return author.first_name || author.email || "Someone";
|
||||
}
|
||||
|
||||
// Mirrors desktop's activityPresentation: who did what, and which glyph.
|
||||
function describe(
|
||||
item: TaskActivityItem,
|
||||
currentEmail?: string | null,
|
||||
): { action: string; icon: AgentIcon } {
|
||||
const inSpace = !!item.channelName;
|
||||
switch (item.activityKind) {
|
||||
case "awaiting_input":
|
||||
return { action: "Agent is waiting for your reply", icon: "question" };
|
||||
case "completed":
|
||||
return { action: "Agent finished", icon: "check" };
|
||||
case "message":
|
||||
if (!item.author) return { action: "Agent replied", icon: "chat" };
|
||||
return {
|
||||
action:
|
||||
item.author.email === currentEmail
|
||||
? "You replied"
|
||||
: `${authorName(item)} replied`,
|
||||
icon: null,
|
||||
};
|
||||
case "mention":
|
||||
return { action: `${authorName(item)} mentioned you`, icon: null };
|
||||
case "thread_reply":
|
||||
return { action: `${authorName(item)} replied to a thread`, icon: null };
|
||||
case "owned_item_comment":
|
||||
return {
|
||||
action: `${authorName(item)} commented on your task`,
|
||||
icon: null,
|
||||
};
|
||||
case "created":
|
||||
return {
|
||||
action: inSpace ? "You created task in" : "You created",
|
||||
icon: null,
|
||||
};
|
||||
default:
|
||||
return { action: "Activity", icon: null };
|
||||
}
|
||||
}
|
||||
|
||||
export function toRows(
|
||||
page: TaskActivityPage | undefined,
|
||||
currentEmail?: string | null,
|
||||
currentName?: string | null,
|
||||
): ActivityRow[] {
|
||||
if (!page) return [];
|
||||
return toTaskActivityItems(page.results).map((item) => {
|
||||
const { action, icon } = describe(item, currentEmail);
|
||||
const space = item.channelName
|
||||
? item.channelName === "personal"
|
||||
? "Personal"
|
||||
: item.channelName
|
||||
: null;
|
||||
const suffix = space && !action.endsWith(" in") ? " in" : "";
|
||||
return {
|
||||
item,
|
||||
icon,
|
||||
initials: icon
|
||||
? null
|
||||
: (item.author?.first_name ?? currentName ?? "You")
|
||||
.slice(0, 2)
|
||||
.toUpperCase(),
|
||||
metadata: `${formatRelativeAge(item.activityAt)} · ${action}${suffix}`,
|
||||
space,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function groupByDay(
|
||||
rows: ActivityRow[],
|
||||
): Array<{ label: string; rows: ActivityRow[] }> {
|
||||
const groups: Array<{ label: string; rows: ActivityRow[] }> = [];
|
||||
for (const row of rows) {
|
||||
const label = getRelativeDateGroup(row.item.activityAt) ?? "Today";
|
||||
const last = groups[groups.length - 1];
|
||||
if (last?.label === label) last.rows.push(row);
|
||||
else groups.push({ label, rows: [row] });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { fetch } from "expo/fetch";
|
||||
import { requireSession, sessionIdentity, useAuth } from "@/lib/auth";
|
||||
|
||||
export type FetchInit = NonNullable<Parameters<typeof fetch>[1]>;
|
||||
|
||||
export function getBaseUrl(): string {
|
||||
return requireSession().host;
|
||||
}
|
||||
|
||||
let pendingRefresh: { identity: string; promise: Promise<string> } | null =
|
||||
null;
|
||||
|
||||
// One refresh at a time, so parallel 401s do not race each other's tokens.
|
||||
export function refreshAccessTokenOnce(): Promise<string> {
|
||||
const identity = sessionIdentity();
|
||||
if (!pendingRefresh || pendingRefresh.identity !== identity) {
|
||||
const promise = useAuth
|
||||
.getState()
|
||||
.refresh()
|
||||
.finally(() => {
|
||||
if (pendingRefresh?.promise === promise) pendingRefresh = null;
|
||||
});
|
||||
pendingRefresh = { identity, promise };
|
||||
}
|
||||
return pendingRefresh.promise;
|
||||
}
|
||||
|
||||
export function getProjectId(): number {
|
||||
return requireSession().projectId;
|
||||
}
|
||||
|
||||
export function getAccessToken(): string {
|
||||
return requireSession().apiKey;
|
||||
}
|
||||
|
||||
// Hermes has no AbortSignal.timeout, so build one by hand.
|
||||
export function createTimeoutSignal(ms: number): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function mergeHeaders(
|
||||
base: Record<string, string>,
|
||||
override: HeadersInit | undefined,
|
||||
): Record<string, string> {
|
||||
const merged = { ...base };
|
||||
if (!override) return merged;
|
||||
if (override instanceof Headers) {
|
||||
override.forEach((value, key) => {
|
||||
merged[key] = value;
|
||||
});
|
||||
} else if (Array.isArray(override)) {
|
||||
for (const [key, value] of override) merged[key] = value;
|
||||
} else {
|
||||
Object.assign(merged, override);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function authedFetch(
|
||||
url: string,
|
||||
init?: FetchInit,
|
||||
): Promise<Response> {
|
||||
const identity = sessionIdentity();
|
||||
const assertCurrent = (): void => {
|
||||
if (sessionIdentity() !== identity)
|
||||
throw new Error("Session changed. Sign in again.");
|
||||
};
|
||||
const headers = mergeHeaders(
|
||||
{
|
||||
Authorization: `Bearer ${getAccessToken()}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "posthog/mobilehog; version: 0.1.0",
|
||||
},
|
||||
init?.headers as HeadersInit | undefined,
|
||||
);
|
||||
// The login session cookie must not ride along, or Django takes the session
|
||||
// path and rejects the POST for a missing CSRF token.
|
||||
const response = await fetch(url, { ...init, headers, credentials: "omit" });
|
||||
assertCurrent();
|
||||
if (response.status !== 401 || !requireSession().refreshToken) {
|
||||
return response;
|
||||
}
|
||||
const token = await refreshAccessTokenOnce();
|
||||
assertCurrent();
|
||||
const retried = await fetch(url, {
|
||||
...init,
|
||||
headers: { ...headers, Authorization: `Bearer ${token}` },
|
||||
credentials: "omit",
|
||||
});
|
||||
assertCurrent();
|
||||
return retried;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
PreparedTaskArtifactUpload,
|
||||
TaskArtifactUploadRequest,
|
||||
} from "@posthog/api-client/posthog-client";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { getClient } from "@/lib/client";
|
||||
|
||||
export const MAX_PHOTOS = 10;
|
||||
const MAX_PHOTO_BYTES = 30 * 1024 * 1024;
|
||||
|
||||
export interface Photo {
|
||||
id: string;
|
||||
uri: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function pickPhotos(remaining: number): Promise<Photo[]> {
|
||||
if (remaining <= 0) return [];
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
allowsMultipleSelection: true,
|
||||
selectionLimit: remaining,
|
||||
orderedSelection: true,
|
||||
quality: 0.8,
|
||||
// HEIC comes back as JPEG, which the agent can read.
|
||||
preferredAssetRepresentationMode:
|
||||
ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Compatible,
|
||||
});
|
||||
if (result.canceled) return [];
|
||||
return result.assets
|
||||
.slice(0, remaining)
|
||||
.filter((asset) => (asset.fileSize ?? 0) <= MAX_PHOTO_BYTES)
|
||||
.map((asset, index) => ({
|
||||
id: `${Date.now()}-${index}`,
|
||||
uri: asset.uri,
|
||||
name: asset.fileName ?? `photo-${index + 1}.jpg`,
|
||||
mimeType: asset.mimeType ?? "image/jpeg",
|
||||
size: asset.fileSize ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function uploadRequests(photos: Photo[]): TaskArtifactUploadRequest[] {
|
||||
return photos.map((photo) => ({
|
||||
name: photo.name,
|
||||
type: "user_attachment",
|
||||
source: "posthog_code",
|
||||
size: photo.size,
|
||||
content_type: photo.mimeType,
|
||||
}));
|
||||
}
|
||||
|
||||
// Streams each file straight from disk to object storage; nothing is base64 encoded.
|
||||
async function postFiles(
|
||||
photos: Photo[],
|
||||
prepared: PreparedTaskArtifactUpload[],
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
prepared.map(async (artifact, index) => {
|
||||
const photo = photos[index];
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(
|
||||
artifact.presigned_post.fields,
|
||||
)) {
|
||||
form.append(key, value);
|
||||
}
|
||||
form.append("file", {
|
||||
uri: photo.uri,
|
||||
name: photo.name,
|
||||
type: photo.mimeType,
|
||||
} as unknown as Blob);
|
||||
const response = await fetch(artifact.presigned_post.url, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Could not upload ${photo.name}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Photos for a task that has no run yet; the run picks them up when it starts.
|
||||
export async function uploadStagedPhotos(
|
||||
taskId: string,
|
||||
photos: Photo[],
|
||||
): Promise<string[]> {
|
||||
if (!photos.length) return [];
|
||||
const client = getClient();
|
||||
const prepared = await client.prepareTaskStagedArtifactUploads(
|
||||
taskId,
|
||||
uploadRequests(photos),
|
||||
);
|
||||
await postFiles(photos, prepared);
|
||||
const finalized = await client.finalizeTaskStagedArtifactUploads(
|
||||
taskId,
|
||||
prepared,
|
||||
);
|
||||
return finalized.map((artifact) => artifact.id);
|
||||
}
|
||||
|
||||
export async function uploadRunPhotos(
|
||||
taskId: string,
|
||||
runId: string,
|
||||
photos: Photo[],
|
||||
): Promise<string[]> {
|
||||
if (!photos.length) return [];
|
||||
const client = getClient();
|
||||
const prepared = await client.prepareTaskRunArtifactUploads(
|
||||
taskId,
|
||||
runId,
|
||||
uploadRequests(photos),
|
||||
);
|
||||
await postFiles(photos, prepared);
|
||||
const finalized = await client.finalizeTaskRunArtifactUploads(
|
||||
taskId,
|
||||
runId,
|
||||
prepared,
|
||||
);
|
||||
return finalized.map((artifact) => artifact.id);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { fetch } from "expo/fetch";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { create } from "zustand";
|
||||
import { POSTHOG_HOST } from "@/config";
|
||||
import {
|
||||
CLOUD_HOSTS,
|
||||
type CloudRegion,
|
||||
refreshOAuth,
|
||||
type SignInOptions,
|
||||
signInWithOAuth,
|
||||
} from "@/lib/oauth";
|
||||
|
||||
const SESSION_KEY = "mobilehog_session";
|
||||
|
||||
export type Region = "local" | CloudRegion;
|
||||
|
||||
export interface Session {
|
||||
region: Region;
|
||||
host: string;
|
||||
apiKey: string;
|
||||
// OAuth sessions only; a local dev key never expires.
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
// The projects an OAuth token may reach; empty or missing means no limit.
|
||||
scopedTeams?: number[];
|
||||
projectId: number;
|
||||
projectName: string;
|
||||
userId: number;
|
||||
userName: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
session: Session | null;
|
||||
hydrated: boolean;
|
||||
generation: number;
|
||||
hydrate: () => Promise<void>;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
loginWithOAuth: (
|
||||
region: CloudRegion,
|
||||
options?: SignInOptions,
|
||||
) => Promise<void>;
|
||||
// Swaps an expired OAuth access token; returns the new bearer.
|
||||
refresh: () => Promise<string>;
|
||||
selectProject: (projectId: number, projectName: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, what: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
let detail = text;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
detail = parsed.detail ?? parsed.error ?? text;
|
||||
} catch {}
|
||||
throw new Error(`${what} failed (${response.status}): ${detail}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
// Log in without touching the cookie jar (a stale session there would trip the
|
||||
// CSRF check), pull the session id out of the response, and swap it for the
|
||||
// seeded local dev personal API key so every later call is a plain bearer.
|
||||
async function loginWithPassword(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<Session> {
|
||||
const loginResponse = await fetch(`${POSTHOG_HOST}/api/login/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: "omit",
|
||||
});
|
||||
await readJson<{ success?: boolean }>(loginResponse, "Login");
|
||||
const sessionId = /sessionid=([^;,\s]+)/.exec(
|
||||
loginResponse.headers.get("set-cookie") ?? "",
|
||||
)?.[1];
|
||||
if (!sessionId) {
|
||||
throw new Error("Login succeeded but no session cookie came back.");
|
||||
}
|
||||
|
||||
const keysResponse = await fetch(`${POSTHOG_HOST}/api/personal_api_keys/`, {
|
||||
headers: { Cookie: `sessionid=${sessionId}` },
|
||||
credentials: "omit",
|
||||
});
|
||||
const keys = await readJson<Array<{ local_dev_value?: string | null }>>(
|
||||
keysResponse,
|
||||
"Reading API keys",
|
||||
);
|
||||
const apiKey = keys.find((key) => key.local_dev_value)?.local_dev_value;
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"No local dev API key found. Run `python manage.py setup_local_api_key` and set ALLOW_DEV_API_KEY_REVEAL=1, then restart the stack.",
|
||||
);
|
||||
}
|
||||
|
||||
return describeSession({ region: "local", host: POSTHOG_HOST, apiKey });
|
||||
}
|
||||
|
||||
async function describeSession(base: {
|
||||
region: Region;
|
||||
host: string;
|
||||
apiKey: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
projectId?: number;
|
||||
}): Promise<Session> {
|
||||
const meResponse = await fetch(`${base.host}/api/users/@me/`, {
|
||||
headers: { Authorization: `Bearer ${base.apiKey}` },
|
||||
credentials: "omit",
|
||||
});
|
||||
const me = await readJson<{
|
||||
id: number;
|
||||
first_name: string;
|
||||
email: string;
|
||||
team?: { id: number; name: string } | null;
|
||||
}>(meResponse, "Loading user");
|
||||
const projectId = base.projectId ?? me.team?.id;
|
||||
if (!projectId) {
|
||||
throw new Error("This user has no current project.");
|
||||
}
|
||||
let projectName = me.team?.id === projectId ? me.team.name : null;
|
||||
if (!projectName) {
|
||||
const projectResponse = await fetch(
|
||||
`${base.host}/api/projects/${projectId}/`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${base.apiKey}` },
|
||||
credentials: "omit",
|
||||
},
|
||||
);
|
||||
projectName =
|
||||
(await readJson<{ name?: string }>(projectResponse, "Loading project"))
|
||||
.name ?? `Project ${projectId}`;
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
projectId,
|
||||
projectName,
|
||||
userId: me.id,
|
||||
userName: me.first_name || me.email,
|
||||
email: me.email,
|
||||
};
|
||||
}
|
||||
|
||||
let storageWrite: Promise<void> = Promise.resolve();
|
||||
let loginPending = false;
|
||||
|
||||
function queueStorageWrite<T>(write: () => Promise<T>): Promise<T> {
|
||||
const pending = storageWrite.then(write);
|
||||
storageWrite = pending.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function sessionIdentity(state = useAuth.getState()): string {
|
||||
const { session, generation } = state;
|
||||
return JSON.stringify([
|
||||
generation,
|
||||
session?.host,
|
||||
session?.projectId,
|
||||
session?.userId,
|
||||
]);
|
||||
}
|
||||
|
||||
export function accountStorageKey(prefix: string): string {
|
||||
const session = requireSession();
|
||||
const host = Array.from(session.host, (character) =>
|
||||
character.charCodeAt(0).toString(16),
|
||||
).join("-");
|
||||
return `${prefix}_${host}_${session.projectId}_${session.userId}`;
|
||||
}
|
||||
|
||||
export const useAuth = create<AuthState>((set, get) => {
|
||||
// The builder runs inside the queued write so it sees every earlier commit,
|
||||
// such as a token refresh that finished while a project switch waited.
|
||||
const commit = async (
|
||||
build: (current: Session | null) => Session,
|
||||
generation: number,
|
||||
): Promise<Session> => {
|
||||
return queueStorageWrite(async () => {
|
||||
if (get().generation !== generation)
|
||||
throw new Error("Session changed. Sign in again.");
|
||||
const session = build(get().session);
|
||||
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
|
||||
if (get().generation !== generation)
|
||||
throw new Error("Session changed. Sign in again.");
|
||||
set({ session, hydrated: true });
|
||||
return session;
|
||||
});
|
||||
};
|
||||
|
||||
const login = async (attempt: () => Promise<Session>): Promise<void> => {
|
||||
if (loginPending) return;
|
||||
loginPending = true;
|
||||
if (get().session || !get().hydrated) {
|
||||
set({ session: null, generation: get().generation + 1, hydrated: true });
|
||||
}
|
||||
const generation = get().generation;
|
||||
try {
|
||||
const session = await attempt();
|
||||
await commit(() => session, generation);
|
||||
} finally {
|
||||
loginPending = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
session: null,
|
||||
hydrated: false,
|
||||
generation: 0,
|
||||
|
||||
hydrate: async () => {
|
||||
if (get().hydrated) return;
|
||||
const generation = get().generation;
|
||||
try {
|
||||
const raw = await SecureStore.getItemAsync(SESSION_KEY);
|
||||
if (get().generation !== generation || get().session) return;
|
||||
set({
|
||||
session: raw ? (JSON.parse(raw) as Session) : null,
|
||||
hydrated: true,
|
||||
});
|
||||
} catch {
|
||||
if (get().generation === generation) set({ hydrated: true });
|
||||
}
|
||||
},
|
||||
|
||||
login: (email, password) => login(() => loginWithPassword(email, password)),
|
||||
|
||||
loginWithOAuth: (region, options) =>
|
||||
login(async () => {
|
||||
const tokens = await signInWithOAuth(region, options);
|
||||
const session = await describeSession({
|
||||
region,
|
||||
host: CLOUD_HOSTS[region],
|
||||
apiKey: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: Date.now() + tokens.expires_in * 1000,
|
||||
projectId: tokens.scoped_teams?.[0],
|
||||
});
|
||||
return { ...session, scopedTeams: tokens.scoped_teams };
|
||||
}),
|
||||
|
||||
refresh: async () => {
|
||||
const { session: current, generation } = get();
|
||||
if (!current?.refreshToken || current.region === "local") {
|
||||
throw new Error("Session cannot be refreshed");
|
||||
}
|
||||
const tokens = await refreshOAuth(current.region, current.refreshToken);
|
||||
const session = await commit((latest) => {
|
||||
if (!latest || latest.refreshToken !== current.refreshToken)
|
||||
throw new Error("Session changed. Sign in again.");
|
||||
return {
|
||||
...latest,
|
||||
apiKey: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || current.refreshToken,
|
||||
expiresAt: Date.now() + tokens.expires_in * 1000,
|
||||
scopedTeams: tokens.scoped_teams ?? latest.scopedTeams,
|
||||
};
|
||||
}, generation);
|
||||
return session.apiKey;
|
||||
},
|
||||
|
||||
selectProject: async (projectId, projectName) => {
|
||||
const current = get().session;
|
||||
if (!current || current.projectId === projectId) return;
|
||||
await commit((latest) => {
|
||||
if (!latest) throw new Error("Session changed. Sign in again.");
|
||||
return { ...latest, projectId, projectName };
|
||||
}, get().generation);
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
set({ session: null, generation: get().generation + 1, hydrated: true });
|
||||
await queueStorageWrite(() => SecureStore.deleteItemAsync(SESSION_KEY));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export function requireSession(): Session {
|
||||
const { session } = useAuth.getState();
|
||||
if (!session) {
|
||||
throw new Error("Not signed in");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { hydrate, type QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
type PersistedClient,
|
||||
persistQueryClientSubscribe,
|
||||
} from "@tanstack/react-query-persist-client";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { createMMKV, deleteMMKV, type MMKV } from "react-native-mmkv";
|
||||
import { type Session, useAuth } from "@/lib/auth";
|
||||
import type { Block } from "@/lib/transcript";
|
||||
|
||||
export const CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000;
|
||||
const KEY_NAME = "mobilehog_cache_key";
|
||||
const CACHED_QUERIES = new Set(["tasks", "reports", "activity", "models"]);
|
||||
|
||||
const stores = new Map<string, MMKV>();
|
||||
|
||||
// Cached tasks can hold code and secrets, so the store is encrypted with a device key.
|
||||
function encryptionKey(): string {
|
||||
const saved = SecureStore.getItem(KEY_NAME);
|
||||
if (saved) return saved;
|
||||
const key = Array.from(Crypto.getRandomBytes(16), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
SecureStore.setItem(KEY_NAME, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
function storeId(session: Session): string {
|
||||
const host = Array.from(session.host, (character) =>
|
||||
character.charCodeAt(0).toString(16),
|
||||
).join("");
|
||||
return `cache-${host}-${session.userId}`;
|
||||
}
|
||||
|
||||
export function accountStore(session: Session): MMKV {
|
||||
const id = storeId(session);
|
||||
let store = stores.get(id);
|
||||
if (!store) {
|
||||
store = createMMKV({
|
||||
id,
|
||||
encryptionKey: encryptionKey(),
|
||||
encryptionType: "AES-256",
|
||||
});
|
||||
stores.set(id, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
export function clearAccountCache(session: Session): void {
|
||||
const id = storeId(session);
|
||||
stores.delete(id);
|
||||
deleteMMKV(id);
|
||||
}
|
||||
|
||||
// Restores synchronously so saved lists render on the first frame.
|
||||
export function persistQueryCache(
|
||||
client: QueryClient,
|
||||
session: Session,
|
||||
): () => void {
|
||||
const store = accountStore(session);
|
||||
const key = `queries-${session.projectId}`;
|
||||
const raw = store.getString(key);
|
||||
if (raw) {
|
||||
try {
|
||||
const saved = JSON.parse(raw) as PersistedClient;
|
||||
if (Date.now() - saved.timestamp < CACHE_MAX_AGE) {
|
||||
hydrate(client, saved.clientState);
|
||||
}
|
||||
} catch {
|
||||
store.remove(key);
|
||||
}
|
||||
}
|
||||
const persister = createSyncStoragePersister({
|
||||
key,
|
||||
throttleTime: 1000,
|
||||
storage: {
|
||||
getItem: (name) => store.getString(name) ?? null,
|
||||
setItem: (name, value) => store.set(name, value),
|
||||
removeItem: (name) => {
|
||||
store.remove(name);
|
||||
},
|
||||
},
|
||||
});
|
||||
return persistQueryClientSubscribe({
|
||||
queryClient: client,
|
||||
persister,
|
||||
buster: "",
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
query.state.status === "success" &&
|
||||
CACHED_QUERIES.has(String(query.queryKey[0])) &&
|
||||
!query.queryKey.includes("search"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const TRANSCRIPT_LIMIT = 50;
|
||||
|
||||
interface SavedTranscript {
|
||||
savedAt: number;
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
function transcriptScope(): { store: MMKV; prefix: string } | null {
|
||||
const { session } = useAuth.getState();
|
||||
if (!session) return null;
|
||||
return {
|
||||
store: accountStore(session),
|
||||
prefix: `transcript-${session.projectId}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadTranscript(taskId: string): Block[] | null {
|
||||
const scope = transcriptScope();
|
||||
const raw = scope?.store.getString(`${scope.prefix}-${taskId}`);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const saved = JSON.parse(raw) as SavedTranscript;
|
||||
return Date.now() - saved.savedAt < CACHE_MAX_AGE ? saved.blocks : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Keeps the most recently saved chats and drops the oldest past the limit.
|
||||
export function saveTranscript(taskId: string, blocks: Block[]): void {
|
||||
const scope = transcriptScope();
|
||||
if (!scope || blocks.length === 0) return;
|
||||
const { store, prefix } = scope;
|
||||
const saved: SavedTranscript = { savedAt: Date.now(), blocks };
|
||||
store.set(`${prefix}-${taskId}`, JSON.stringify(saved));
|
||||
const indexKey = `${prefix}-index`;
|
||||
let order: string[] = [];
|
||||
try {
|
||||
order = JSON.parse(store.getString(indexKey) ?? "[]");
|
||||
} catch {}
|
||||
order = [taskId, ...order.filter((id) => id !== taskId)];
|
||||
for (const stale of order.slice(TRANSCRIPT_LIMIT)) {
|
||||
store.remove(`${prefix}-${stale}`);
|
||||
}
|
||||
store.set(indexKey, JSON.stringify(order.slice(0, TRANSCRIPT_LIMIT)));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { FetchImplementation } from "@posthog/api-client/fetcher";
|
||||
import { PostHogAPIClient } from "@posthog/api-client/posthog-client";
|
||||
import { fetch } from "expo/fetch";
|
||||
import {
|
||||
getAccessToken,
|
||||
getBaseUrl,
|
||||
getProjectId,
|
||||
refreshAccessTokenOnce,
|
||||
} from "@/lib/api";
|
||||
import { sessionIdentity } from "@/lib/auth";
|
||||
|
||||
const nativeFetch: FetchImplementation = (input, init) =>
|
||||
fetch(
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url,
|
||||
{ ...init, credentials: "omit" },
|
||||
);
|
||||
|
||||
let client: PostHogAPIClient | null = null;
|
||||
let clientIdentity: string | null = null;
|
||||
|
||||
export function getClient(): PostHogAPIClient {
|
||||
const identity = sessionIdentity();
|
||||
const projectId = getProjectId();
|
||||
const host = getBaseUrl();
|
||||
if (!client || clientIdentity !== identity) {
|
||||
const assertCurrent = (): void => {
|
||||
if (sessionIdentity() !== identity)
|
||||
throw new Error("Session changed. Sign in again.");
|
||||
};
|
||||
client = new PostHogAPIClient(
|
||||
host,
|
||||
async () => {
|
||||
assertCurrent();
|
||||
return getAccessToken();
|
||||
},
|
||||
async () => {
|
||||
assertCurrent();
|
||||
const token = await refreshAccessTokenOnce();
|
||||
assertCurrent();
|
||||
return token;
|
||||
},
|
||||
projectId,
|
||||
{
|
||||
appVersion: "0.1.0",
|
||||
fetch: async (input, init) => {
|
||||
assertCurrent();
|
||||
const response = await nativeFetch(input, init);
|
||||
assertCurrent();
|
||||
return response;
|
||||
},
|
||||
githubConnectFrom: "posthog_mobile",
|
||||
userAgent: "posthog/mobilehog; version: 0.1.0",
|
||||
},
|
||||
);
|
||||
clientIdentity = identity;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export function resetClient(): void {
|
||||
client = null;
|
||||
clientIdentity = null;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Adapter } from "@posthog/shared";
|
||||
import {
|
||||
adapterForModelId,
|
||||
DEFAULT_GATEWAY_MODEL,
|
||||
getReasoningEffortOptions,
|
||||
type SupportedReasoningEffort,
|
||||
supportsFastMode,
|
||||
} from "@posthog/shared";
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ComposerConfig {
|
||||
// The harness follows the model: GPT models run on Codex, the rest on Claude Code.
|
||||
adapter: Adapter;
|
||||
model: string;
|
||||
reasoning: SupportedReasoningEffort;
|
||||
}
|
||||
|
||||
interface ComposerState extends ComposerConfig {
|
||||
setModel: (model: string) => void;
|
||||
setReasoning: (reasoning: SupportedReasoningEffort) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const defaults: ComposerConfig = {
|
||||
adapter: "claude",
|
||||
model: DEFAULT_GATEWAY_MODEL,
|
||||
reasoning: "high",
|
||||
};
|
||||
|
||||
// Keep the effort valid for the model, preferring the current one.
|
||||
function clampReasoning(
|
||||
adapter: Adapter,
|
||||
model: string,
|
||||
reasoning: SupportedReasoningEffort,
|
||||
): SupportedReasoningEffort {
|
||||
const options = getReasoningEffortOptions(adapter, model);
|
||||
if (!options || options.some((option) => option.value === reasoning)) {
|
||||
return reasoning;
|
||||
}
|
||||
return options[options.length - 1]?.value ?? reasoning;
|
||||
}
|
||||
|
||||
export const useComposer = create<ComposerState>((set, get) => ({
|
||||
...defaults,
|
||||
setModel: (model) => {
|
||||
const adapter = adapterForModelId(model);
|
||||
set({
|
||||
model,
|
||||
adapter,
|
||||
reasoning: clampReasoning(adapter, model, get().reasoning),
|
||||
});
|
||||
},
|
||||
setReasoning: (reasoning) => set({ reasoning }),
|
||||
reset: () => set({ ...defaults }),
|
||||
}));
|
||||
|
||||
// The run request fields for the current picks, in api-client's names.
|
||||
export function currentRunConfig() {
|
||||
const { adapter, model, reasoning } = useComposer.getState();
|
||||
return {
|
||||
adapter,
|
||||
model,
|
||||
reasoningLevel:
|
||||
getReasoningEffortOptions(adapter, model) === null
|
||||
? undefined
|
||||
: reasoning,
|
||||
...(supportsFastMode(model) ? { fastMode: false } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Easing,
|
||||
type SharedValue,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { RING, SAMPLE_MS } from "@/components/Waveform";
|
||||
|
||||
export interface Dictation {
|
||||
active: boolean;
|
||||
transcript: string;
|
||||
// Read on the UI thread by the waveform; updating them never re-renders React.
|
||||
levels: SharedValue<number[]>;
|
||||
head: SharedValue<number>;
|
||||
start: () => void;
|
||||
// Ends dictation and keeps what was heard.
|
||||
stop: () => string;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
const emptyRing = (): number[] => new Array(RING).fill(-1);
|
||||
|
||||
export function useDictation(): Dictation {
|
||||
const [active, setActive] = useState(false);
|
||||
const [transcript, setTranscript] = useState("");
|
||||
const levels = useSharedValue<number[]>(emptyRing());
|
||||
const head = useSharedValue(-1);
|
||||
const count = useRef(0);
|
||||
|
||||
const push = useCallback(
|
||||
(level: number) => {
|
||||
const id = count.current++;
|
||||
levels.modify((ring) => {
|
||||
"worklet";
|
||||
ring[id % RING] = level;
|
||||
return ring;
|
||||
});
|
||||
head.value = withTiming(id, {
|
||||
duration: SAMPLE_MS,
|
||||
easing: Easing.linear,
|
||||
});
|
||||
},
|
||||
[levels, head],
|
||||
);
|
||||
|
||||
// DEMO: bursts of speech-like levels until the recognizer is wired in.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
let speaking = true;
|
||||
let flipAt = Date.now() + 900;
|
||||
const timer = setInterval(() => {
|
||||
if (Date.now() > flipAt) {
|
||||
speaking = !speaking;
|
||||
flipAt =
|
||||
Date.now() +
|
||||
(speaking ? 600 + Math.random() * 900 : 250 + Math.random() * 400);
|
||||
}
|
||||
push(speaking ? 0.25 + Math.random() * 0.75 : 0);
|
||||
}, SAMPLE_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [active, push]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
count.current = 0;
|
||||
levels.value = emptyRing();
|
||||
head.value = -1;
|
||||
setTranscript("");
|
||||
setActive(true);
|
||||
}, [levels, head]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
setActive(false);
|
||||
return transcript;
|
||||
}, [transcript]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setActive(false);
|
||||
setTranscript("");
|
||||
}, []);
|
||||
|
||||
return { active, transcript, levels, head, start, stop, cancel };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
type CloudTaskEngine,
|
||||
type CloudTaskFetch,
|
||||
createCloudTaskEngine,
|
||||
} from "@posthog/core/cloud-task/cloud-task-engine";
|
||||
import { CloudTaskEvent } from "@posthog/core/cloud-task/schemas";
|
||||
import type { CloudTaskUpdatePayload } from "@posthog/shared";
|
||||
import { fetch } from "expo/fetch";
|
||||
import {
|
||||
authedFetch,
|
||||
type FetchInit,
|
||||
getBaseUrl,
|
||||
getProjectId,
|
||||
} from "@/lib/api";
|
||||
import { sessionIdentity } from "@/lib/auth";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const noopAnalytics = {
|
||||
initialize: () => {},
|
||||
track: () => {},
|
||||
identify: () => {},
|
||||
setCurrentUserId: () => {},
|
||||
getCurrentUserId: () => null,
|
||||
getOrCreateSessionId: () => "mobilehog",
|
||||
resetUser: () => {},
|
||||
captureException: () => {},
|
||||
flush: async () => {},
|
||||
shutdown: async () => {},
|
||||
};
|
||||
|
||||
let engine: CloudTaskEngine | null = null;
|
||||
|
||||
function getEngine(): CloudTaskEngine {
|
||||
if (engine) return engine;
|
||||
const identity = sessionIdentity();
|
||||
const assertCurrent = (): void => {
|
||||
if (sessionIdentity() !== identity)
|
||||
throw new Error("Session changed. Sign in again.");
|
||||
};
|
||||
engine = createCloudTaskEngine({
|
||||
auth: {
|
||||
authenticatedFetch: (url, init) => {
|
||||
assertCurrent();
|
||||
return authedFetch(url, init as FetchInit | undefined);
|
||||
},
|
||||
getCloudContext: async () => {
|
||||
assertCurrent();
|
||||
return { apiHost: getBaseUrl(), teamId: getProjectId() };
|
||||
},
|
||||
},
|
||||
analytics: noopAnalytics,
|
||||
logger,
|
||||
// React Native's global fetch cannot stream a body; expo/fetch can.
|
||||
streamFetch: ((url, init) =>
|
||||
fetch(url, { ...init, credentials: "omit" })) as CloudTaskFetch,
|
||||
});
|
||||
return engine;
|
||||
}
|
||||
|
||||
export function resetEngine(): void {
|
||||
engine?.unwatchAll();
|
||||
engine = null;
|
||||
}
|
||||
|
||||
export interface WatchHandle {
|
||||
stop: () => void;
|
||||
reconnectIfDisconnected: () => void;
|
||||
}
|
||||
|
||||
export function watchRun(
|
||||
taskId: string,
|
||||
runId: string,
|
||||
onUpdate: (update: CloudTaskUpdatePayload) => void,
|
||||
): WatchHandle {
|
||||
const instance = getEngine();
|
||||
const listener = (update: CloudTaskUpdatePayload): void => {
|
||||
if (update.taskId === taskId && update.runId === runId) {
|
||||
onUpdate(update);
|
||||
}
|
||||
};
|
||||
instance.on(CloudTaskEvent.Update, listener);
|
||||
// A watcher for this run may survive a reload; drop it so we get a snapshot.
|
||||
instance.unwatch(taskId, runId);
|
||||
instance.watch({
|
||||
taskId,
|
||||
runId,
|
||||
apiHost: getBaseUrl(),
|
||||
teamId: getProjectId(),
|
||||
});
|
||||
|
||||
let stopped = false;
|
||||
return {
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
instance.off(CloudTaskEvent.Update, listener);
|
||||
instance.unwatch(taskId, runId);
|
||||
},
|
||||
reconnectIfDisconnected: () => {
|
||||
if (!stopped) instance.reconnectIfDisconnected(taskId, runId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
type LogFn = (message: string, ...args: unknown[]) => void;
|
||||
|
||||
export interface Logger {
|
||||
debug: LogFn;
|
||||
info: LogFn;
|
||||
warn: LogFn;
|
||||
error: LogFn;
|
||||
scope: (name: string) => Logger;
|
||||
}
|
||||
|
||||
function createLogger(scope?: string): Logger {
|
||||
const prefix = scope ? `[${scope}]` : "";
|
||||
if (!__DEV__) {
|
||||
const noop: LogFn = () => {};
|
||||
return {
|
||||
debug: noop,
|
||||
info: noop,
|
||||
warn: noop,
|
||||
error: noop,
|
||||
scope: () => createLogger(scope),
|
||||
};
|
||||
}
|
||||
return {
|
||||
debug: (message, ...args) => console.debug(prefix, message, ...args),
|
||||
info: (message, ...args) => console.info(prefix, message, ...args),
|
||||
warn: (message, ...args) => console.warn(prefix, message, ...args),
|
||||
error: (message, ...args) => console.error(prefix, message, ...args),
|
||||
scope: (name) => createLogger(scope ? `${scope}:${name}` : name),
|
||||
};
|
||||
}
|
||||
|
||||
export const logger = createLogger();
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import type {
|
||||
CallToolResult,
|
||||
ReadResourceResult,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { MCP_HOSTS } from "@/config";
|
||||
import { requireSession, sessionIdentity } from "@/lib/auth";
|
||||
|
||||
// The sandbox wrapper sends this too; it is what makes exec results carry the
|
||||
// UI-app resource URI and structured data instead of plain text.
|
||||
const CONSUMER = "posthog-code";
|
||||
|
||||
interface Connection {
|
||||
key: string;
|
||||
client: Promise<Client>;
|
||||
tools?: Promise<Tool[]>;
|
||||
}
|
||||
|
||||
let connection: Connection | null = null;
|
||||
|
||||
export function resetMcpClient(): void {
|
||||
const previous = connection;
|
||||
connection = null;
|
||||
previous?.client.then((client) => client.close()).catch(() => {});
|
||||
}
|
||||
|
||||
// One MCP client to the PostHog server for the signed-in host, opened lazily
|
||||
// and reopened if the session changes.
|
||||
function getConnection(): Connection {
|
||||
const session = requireSession();
|
||||
const key = `${sessionIdentity()}|${session.apiKey}`;
|
||||
if (connection?.key === key) return connection;
|
||||
resetMcpClient();
|
||||
const url = new URL(MCP_HOSTS[session.region]);
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.apiKey}`,
|
||||
"x-posthog-mcp-consumer": CONSUMER,
|
||||
},
|
||||
},
|
||||
});
|
||||
const client = new Client(
|
||||
{ name: "mobilehog", version: "1.0.0" },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
const ready = client.connect(transport).then(() => client);
|
||||
const next: Connection = { key, client: ready };
|
||||
ready.catch(() => {
|
||||
if (connection === next) connection = null;
|
||||
});
|
||||
connection = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
async function getClient(): Promise<Client> {
|
||||
const current = getConnection();
|
||||
const client = await current.client;
|
||||
if (connection !== current)
|
||||
throw new Error("Session changed. Sign in again.");
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function readMcpResource(
|
||||
uri: string,
|
||||
): Promise<ReadResourceResult> {
|
||||
const client = await getClient();
|
||||
return (await client.readResource({ uri })) as ReadResourceResult;
|
||||
}
|
||||
|
||||
export async function callMcpTool(
|
||||
name: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<CallToolResult> {
|
||||
const client = await getClient();
|
||||
return (await client.callTool({
|
||||
name,
|
||||
arguments: args ?? {},
|
||||
})) as CallToolResult;
|
||||
}
|
||||
|
||||
export function listMcpTools(): Promise<Tool[]> {
|
||||
const current = getConnection();
|
||||
if (!current.tools) {
|
||||
current.tools = getClient()
|
||||
.then((client) => client.listTools())
|
||||
.then((result) => result.tools);
|
||||
current.tools.catch(() => {
|
||||
current.tools = undefined;
|
||||
});
|
||||
}
|
||||
return current.tools;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Sandbox proxy HTML for mobile MCP Apps.
|
||||
*
|
||||
* Mirrors the desktop double-iframe pattern (see
|
||||
* apps/code/src/shared/mcp-sandbox-proxy.html) but routes messages through
|
||||
* `react-native-webview`'s bridge instead of `window.parent.postMessage`.
|
||||
*
|
||||
* RN host ←→ WebView (this HTML, the outer "proxy") ←→ Inner iframe (MCP App)
|
||||
*
|
||||
* Wire format on both sides is JSON-RPC, identical to desktop. The differences
|
||||
* are only in how messages cross the boundary:
|
||||
*
|
||||
* - Inbound (RN → WebView): RN calls `window.__mcpReceive(jsonString)` via
|
||||
* `webView.injectJavaScript`.
|
||||
* - Outbound (WebView → RN): the proxy calls
|
||||
* `window.ReactNativeWebView.postMessage(jsonString)`.
|
||||
*
|
||||
* The inner iframe still uses standard `window.parent.postMessage` to reach
|
||||
* the proxy — that part doesn't change.
|
||||
*/
|
||||
export const sandboxProxyHtml = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: transparent; }
|
||||
iframe { border: none; width: 100%; height: 100%; display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function postToHost(data) {
|
||||
try {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(data));
|
||||
} catch (err) {
|
||||
// Best-effort; nothing to do if the bridge is gone.
|
||||
}
|
||||
}
|
||||
|
||||
function log() {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
postToHost({ jsonrpc: "2.0", method: "ui/notifications/log", params: { args: args } });
|
||||
}
|
||||
|
||||
var inner = document.createElement("iframe");
|
||||
inner.setAttribute("sandbox", "allow-scripts allow-forms");
|
||||
document.body.appendChild(inner);
|
||||
|
||||
// contentWindow survives navigation, so an app that calls location.replace()
|
||||
// would keep the bridge. Only the first load of our srcdoc may use it.
|
||||
var appLoaded = false;
|
||||
var bridgeClosed = false;
|
||||
function onInnerLoad() {
|
||||
if (!appLoaded) {
|
||||
appLoaded = true;
|
||||
return;
|
||||
}
|
||||
bridgeClosed = true;
|
||||
}
|
||||
|
||||
function buildAllowAttribute(permissions) {
|
||||
if (!permissions || typeof permissions !== "object") return "";
|
||||
var mapping = {
|
||||
camera: "camera",
|
||||
microphone: "microphone",
|
||||
geolocation: "geolocation",
|
||||
clipboardWrite: "clipboard-write"
|
||||
};
|
||||
return Object.keys(permissions)
|
||||
.filter(function (k) { return mapping[k] && permissions[k] != null; })
|
||||
.map(function (k) { return mapping[k] + " *"; })
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
// Inbound from RN host. The RN side injects:
|
||||
// window.__mcpReceive(JSON.stringify(message))
|
||||
window.__mcpReceive = function (jsonString) {
|
||||
var data;
|
||||
try { data = JSON.parse(jsonString); } catch (e) { return; }
|
||||
if (!data || typeof data !== "object") return;
|
||||
|
||||
if (data.method === "ui/notifications/sandbox-resource-ready") {
|
||||
var params = data.params || {};
|
||||
if (typeof params.html === "string") {
|
||||
var allowValue = buildAllowAttribute(params.permissions);
|
||||
if (allowValue) inner.setAttribute("allow", allowValue);
|
||||
|
||||
inner.addEventListener("load", onInnerLoad);
|
||||
inner.setAttribute("srcdoc", params.html);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All other host messages get relayed into the inner iframe untouched.
|
||||
if (!bridgeClosed && inner.contentWindow) {
|
||||
// An opaque origin serializes to "null", which postMessage rejects.
|
||||
inner.contentWindow.postMessage(data, "*");
|
||||
}
|
||||
};
|
||||
|
||||
// Inner iframe → relay to RN host.
|
||||
window.addEventListener("message", function (event) {
|
||||
var data = event.data;
|
||||
if (!data || typeof data !== "object") return;
|
||||
if (event.source !== inner.contentWindow) return;
|
||||
if (bridgeClosed) return;
|
||||
postToHost(data);
|
||||
});
|
||||
|
||||
// Tell RN we're ready. Retry a few times — RN may not have attached its
|
||||
// onMessage handler yet on first paint.
|
||||
var ready = { jsonrpc: "2.0", method: "ui/notifications/sandbox-proxy-ready", params: {} };
|
||||
var attempts = 0;
|
||||
function announce() {
|
||||
attempts++;
|
||||
postToHost(ready);
|
||||
if (attempts < 4) setTimeout(announce, 50 * attempts);
|
||||
}
|
||||
announce();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { McpUiStyles } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
|
||||
// The app runs in a WebView, so it needs literal colours rather than our
|
||||
// DynamicColorIOS tokens; these mirror the theme's light and dark values.
|
||||
const PALETTES = {
|
||||
light: {
|
||||
bg: "#EEEFE9",
|
||||
surface: "#FFFFFF",
|
||||
raised: "#F7F7F3",
|
||||
ink: "#151515",
|
||||
inkSoft: "#4F5150",
|
||||
inkMute: "#8F918D",
|
||||
line: "#DCDDD6",
|
||||
lineSoft: "#E7E8E1",
|
||||
},
|
||||
dark: {
|
||||
bg: "#151515",
|
||||
surface: "#222222",
|
||||
raised: "#1C1C1C",
|
||||
ink: "#EEEFE9",
|
||||
inkSoft: "#B8BAB4",
|
||||
inkMute: "#7C7E7A",
|
||||
line: "#333333",
|
||||
lineSoft: "#2A2A2A",
|
||||
},
|
||||
};
|
||||
|
||||
const ACCENT = "#F54E00";
|
||||
const DANGER = "#FF474D";
|
||||
const OK = "#36C46F";
|
||||
const WARN = "#F9BD2B";
|
||||
|
||||
export function buildHostStyles(dark: boolean): {
|
||||
variables: McpUiStyles;
|
||||
css: { fonts: string };
|
||||
} {
|
||||
const c = dark ? PALETTES.dark : PALETTES.light;
|
||||
const variables: Record<string, string> = {
|
||||
"--color-background-primary": c.bg,
|
||||
"--color-background-secondary": c.surface,
|
||||
"--color-background-tertiary": c.raised,
|
||||
"--color-background-inverse": c.ink,
|
||||
"--color-background-ghost": "transparent",
|
||||
"--color-background-info": dark ? "#2A1A10" : "#FFEFE6",
|
||||
"--color-background-danger": dark ? "#3b0d0d" : "#fde8e8",
|
||||
"--color-background-success": dark ? "#0f2d18" : "#dcfce7",
|
||||
"--color-background-warning": dark ? "#3a2c00" : "#fef3c7",
|
||||
"--color-background-disabled": c.raised,
|
||||
"--color-text-primary": c.ink,
|
||||
"--color-text-secondary": c.inkSoft,
|
||||
"--color-text-tertiary": c.inkMute,
|
||||
"--color-text-inverse": c.bg,
|
||||
"--color-text-ghost": c.inkMute,
|
||||
"--color-text-info": ACCENT,
|
||||
"--color-text-danger": DANGER,
|
||||
"--color-text-success": OK,
|
||||
"--color-text-warning": WARN,
|
||||
"--color-text-disabled": c.inkMute,
|
||||
"--color-border-primary": c.line,
|
||||
"--color-border-secondary": c.lineSoft,
|
||||
"--color-border-tertiary": c.lineSoft,
|
||||
"--color-border-inverse": c.ink,
|
||||
"--color-border-ghost": "transparent",
|
||||
"--color-border-info": ACCENT,
|
||||
"--color-border-danger": DANGER,
|
||||
"--color-border-success": OK,
|
||||
"--color-border-warning": WARN,
|
||||
"--color-border-disabled": c.lineSoft,
|
||||
"--color-ring-primary": ACCENT,
|
||||
"--color-ring-secondary": c.line,
|
||||
"--color-ring-inverse": c.bg,
|
||||
"--color-ring-info": ACCENT,
|
||||
"--color-ring-danger": DANGER,
|
||||
"--color-ring-success": OK,
|
||||
"--color-ring-warning": WARN,
|
||||
"--font-sans": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
"--font-mono": "ui-monospace, 'JetBrains Mono', 'SF Mono', monospace",
|
||||
"--font-weight-normal": "400",
|
||||
"--font-weight-medium": "500",
|
||||
"--font-weight-semibold": "600",
|
||||
"--font-weight-bold": "700",
|
||||
"--font-text-xs-size": "12px",
|
||||
"--font-text-sm-size": "14px",
|
||||
"--font-text-md-size": "16px",
|
||||
"--font-text-lg-size": "18px",
|
||||
"--font-heading-xs-size": "18px",
|
||||
"--font-heading-sm-size": "20px",
|
||||
"--font-heading-md-size": "24px",
|
||||
"--font-heading-lg-size": "28px",
|
||||
"--font-heading-xl-size": "32px",
|
||||
"--font-heading-2xl-size": "48px",
|
||||
"--font-heading-3xl-size": "60px",
|
||||
"--font-text-xs-line-height": "1.5",
|
||||
"--font-text-sm-line-height": "1.5",
|
||||
"--font-text-md-line-height": "1.5",
|
||||
"--font-text-lg-line-height": "1.5",
|
||||
"--font-heading-xs-line-height": "1.3",
|
||||
"--font-heading-sm-line-height": "1.3",
|
||||
"--font-heading-md-line-height": "1.25",
|
||||
"--font-heading-lg-line-height": "1.25",
|
||||
"--font-heading-xl-line-height": "1.2",
|
||||
"--font-heading-2xl-line-height": "1.2",
|
||||
"--font-heading-3xl-line-height": "1.1",
|
||||
"--border-radius-xs": "4px",
|
||||
"--border-radius-sm": "8px",
|
||||
"--border-radius-md": "12px",
|
||||
"--border-radius-lg": "16px",
|
||||
"--border-radius-xl": "24px",
|
||||
"--border-radius-full": "9999px",
|
||||
"--border-width-regular": "1px",
|
||||
"--shadow-hairline": `0 0 0 1px ${c.line}`,
|
||||
"--shadow-sm": "0 1px 2px rgba(0,0,0,0.05)",
|
||||
"--shadow-md": "0 4px 6px -1px rgba(0,0,0,0.1)",
|
||||
"--shadow-lg": "0 10px 15px -3px rgba(0,0,0,0.1)",
|
||||
};
|
||||
return { variables: variables as unknown as McpUiStyles, css: { fonts: "" } };
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
AppBridge,
|
||||
type McpUiDisplayMode,
|
||||
type McpUiHostCapabilities,
|
||||
type McpUiHostContext,
|
||||
type McpUiResourceCsp,
|
||||
} from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { applyCspToHtml } from "@posthog/core/mcp-apps/csp";
|
||||
import { isSafeExternalUrl } from "@posthog/shared";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { EdgeInsets } from "react-native-safe-area-context";
|
||||
import type WebView from "react-native-webview";
|
||||
import { callMcpTool, listMcpTools, readMcpResource } from "@/lib/mcp/client";
|
||||
import { buildHostStyles } from "@/lib/mcp/theme";
|
||||
import { WebViewTransport } from "@/lib/mcp/webViewTransport";
|
||||
|
||||
export type Phase = "loading" | "proxy-ready" | "initialized" | "error";
|
||||
|
||||
export interface UiResource {
|
||||
uri: string;
|
||||
html: string;
|
||||
csp?: McpUiResourceCsp;
|
||||
permissions?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface Args {
|
||||
webViewRef: { current: WebView | null };
|
||||
resource: UiResource | null | undefined;
|
||||
tool: Tool | null | undefined;
|
||||
toolInput: Record<string, unknown> | undefined;
|
||||
toolResult: CallToolResult | null;
|
||||
dark: boolean;
|
||||
displayMode: McpUiDisplayMode;
|
||||
width: number;
|
||||
insets: EdgeInsets;
|
||||
onPhase: (phase: Phase) => void;
|
||||
onHeight: (height: number) => void;
|
||||
onDisplayMode: (mode: McpUiDisplayMode) => void;
|
||||
}
|
||||
|
||||
const HOST_INFO = { name: "mobilehog", version: "1.0.0" };
|
||||
const CAPABILITIES: McpUiHostCapabilities = {
|
||||
openLinks: {},
|
||||
serverTools: {},
|
||||
serverResources: {},
|
||||
logging: {},
|
||||
message: { text: {} },
|
||||
sandbox: {},
|
||||
};
|
||||
|
||||
function hostContext(args: Args): McpUiHostContext {
|
||||
const styles = buildHostStyles(args.dark);
|
||||
return {
|
||||
theme: args.dark ? "dark" : "light",
|
||||
styles,
|
||||
availableDisplayModes: ["inline", "fullscreen"],
|
||||
displayMode: args.displayMode,
|
||||
containerDimensions: { width: args.width, height: 320 },
|
||||
locale: "en-US",
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
userAgent: `mobilehog/${Platform.OS}`,
|
||||
platform: "mobile",
|
||||
deviceCapabilities: { touch: true, hover: false },
|
||||
safeAreaInsets: args.insets,
|
||||
...(args.tool ? { toolInfo: { tool: args.tool } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// The transcript picks which app mounts, and a shared task's transcript can
|
||||
// come from another person, so an app may only call tools that declare a UI
|
||||
// and allow app calls (the same rule as the desktop MCP apps service).
|
||||
async function isAppCallable(name: string): Promise<boolean> {
|
||||
if (name === "exec") return false;
|
||||
const tool = (await listMcpTools()).find((item) => item.name === name);
|
||||
const ui = (
|
||||
tool?._meta as
|
||||
| { ui?: { resourceUri?: unknown; visibility?: unknown } }
|
||||
| undefined
|
||||
)?.ui;
|
||||
if (typeof ui?.resourceUri !== "string") return false;
|
||||
return !Array.isArray(ui.visibility) || ui.visibility.includes("app");
|
||||
}
|
||||
|
||||
// One ext-apps AppBridge bound to a WebView: the proxy page relays JSON-RPC to
|
||||
// the sandboxed app, and tool or resource calls round-trip to the MCP server.
|
||||
export function useAppBridge(args: Args): {
|
||||
onWebViewMessage: (payload: string) => void;
|
||||
} {
|
||||
const transportRef = useRef<WebViewTransport | null>(null);
|
||||
const bridgeRef = useRef<AppBridge | null>(null);
|
||||
const latest = useRef(args);
|
||||
useLayoutEffect(() => {
|
||||
latest.current = args;
|
||||
});
|
||||
const [proxyGeneration, setProxyGeneration] = useState(0);
|
||||
|
||||
const { webViewRef, resource } = args;
|
||||
useEffect(() => {
|
||||
if (!resource || proxyGeneration === 0) return;
|
||||
let gone = false;
|
||||
|
||||
const transport = new WebViewTransport(webViewRef);
|
||||
const bridge = new AppBridge(null, HOST_INFO, CAPABILITIES, {
|
||||
hostContext: hostContext(latest.current),
|
||||
});
|
||||
transportRef.current = transport;
|
||||
bridgeRef.current = bridge;
|
||||
|
||||
const setup = async () => {
|
||||
latest.current.onPhase("proxy-ready");
|
||||
bridge.oncalltool = async (params) => {
|
||||
if (!(await isAppCallable(params.name))) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Tool "${params.name}" is not accessible to apps`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return callMcpTool(params.name, params.arguments);
|
||||
};
|
||||
bridge.onreadresource = (params) => readMcpResource(params.uri);
|
||||
bridge.onopenlink = async (params) => {
|
||||
if (isSafeExternalUrl(params.url)) {
|
||||
await WebBrowser.openBrowserAsync(params.url);
|
||||
}
|
||||
return {};
|
||||
};
|
||||
bridge.onmessage = async () => ({});
|
||||
bridge.onrequestdisplaymode = async (params) => {
|
||||
if (params.mode === "inline" || params.mode === "fullscreen") {
|
||||
latest.current.onDisplayMode(params.mode);
|
||||
return { mode: params.mode };
|
||||
}
|
||||
return { mode: latest.current.displayMode };
|
||||
};
|
||||
bridge.onsizechange = (params) => {
|
||||
if (typeof params.height === "number" && params.height > 0) {
|
||||
latest.current.onHeight(params.height);
|
||||
}
|
||||
};
|
||||
bridge.oninitialized = () => {
|
||||
if (gone) return;
|
||||
latest.current.onPhase("initialized");
|
||||
if (latest.current.toolInput) {
|
||||
bridge.sendToolInput({ arguments: latest.current.toolInput });
|
||||
}
|
||||
if (latest.current.toolResult) {
|
||||
bridge.sendToolResult(latest.current.toolResult);
|
||||
}
|
||||
};
|
||||
await bridge.connect(transport);
|
||||
if (gone) return;
|
||||
await bridge.sendSandboxResourceReady({
|
||||
html: applyCspToHtml(resource.html, resource.csp),
|
||||
csp: resource.csp,
|
||||
permissions: resource.permissions,
|
||||
});
|
||||
};
|
||||
|
||||
setup().catch(() => {
|
||||
if (!gone) latest.current.onPhase("error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
gone = true;
|
||||
bridge.close().catch(() => {});
|
||||
transport.close().catch(() => {});
|
||||
bridgeRef.current = null;
|
||||
transportRef.current = null;
|
||||
};
|
||||
}, [resource, webViewRef, proxyGeneration]);
|
||||
|
||||
// Theme, size and display mode changes flow to a live app.
|
||||
useEffect(() => {
|
||||
const bridge = bridgeRef.current;
|
||||
if (!bridge) return;
|
||||
bridge.sendHostContextChange({
|
||||
theme: args.dark ? "dark" : "light",
|
||||
styles: buildHostStyles(args.dark),
|
||||
displayMode: args.displayMode,
|
||||
containerDimensions: { width: args.width, height: 320 },
|
||||
safeAreaInsets: args.insets,
|
||||
});
|
||||
}, [args.dark, args.displayMode, args.width, args.insets]);
|
||||
|
||||
const onWebViewMessage = useCallback((payload: string) => {
|
||||
try {
|
||||
if (
|
||||
JSON.parse(payload)?.method === "ui/notifications/sandbox-proxy-ready"
|
||||
) {
|
||||
setProxyGeneration((generation) => generation + 1);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
transportRef.current?.acceptIncoming(payload);
|
||||
}, []);
|
||||
|
||||
return { onWebViewMessage };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type WebView from "react-native-webview";
|
||||
|
||||
/**
|
||||
* MCP `Transport` implementation that bridges JSON-RPC messages between the
|
||||
* RN host and a `react-native-webview`-hosted sandbox proxy.
|
||||
*
|
||||
* Inbound (WebView → RN): the caller hands us messages via `acceptIncoming`
|
||||
* (typically called from the WebView's `onMessage` prop).
|
||||
* Outbound (RN → WebView): `send` injects a tiny JS snippet that invokes the
|
||||
* sandbox proxy's `window.__mcpReceive` entry point.
|
||||
*
|
||||
* The transport never validates origin (there isn't a meaningful one inside a
|
||||
* WebView) — the host MUST only inject HTML it trusts via `WebView`'s
|
||||
* `source`. Since the sandbox proxy HTML is hard-coded in
|
||||
* `sandboxProxyHtml.ts` and the inner iframe's content comes from a UI
|
||||
* resource the user has already chosen to install, that boundary is fine.
|
||||
*/
|
||||
export class WebViewTransport implements Transport {
|
||||
private webViewRef: { current: WebView | null };
|
||||
private started = false;
|
||||
private closed = false;
|
||||
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onclose?: () => void;
|
||||
// Required by Transport; we honour it but the protocol version is not
|
||||
// meaningful at the WebView boundary.
|
||||
setProtocolVersion?: (version: string) => void;
|
||||
|
||||
constructor(webViewRef: { current: WebView | null }) {
|
||||
this.webViewRef = webViewRef;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a JSON-RPC message from the host to the WebView. Idempotent and
|
||||
* safe to call before `start()` — `injectJavaScript` will just no-op until
|
||||
* the WebView is mounted.
|
||||
*/
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (this.closed) throw new Error("Transport closed");
|
||||
const webView = this.webViewRef.current;
|
||||
if (!webView) return;
|
||||
const payload = JSON.stringify(JSON.stringify(message));
|
||||
const snippet = `void (window.__mcpReceive && window.__mcpReceive(${payload}));`;
|
||||
webView.injectJavaScript(snippet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the WebView's `onMessage` handler with the raw JSON payload
|
||||
* the sandbox proxy posted. Parses, validates shape, and dispatches.
|
||||
*/
|
||||
acceptIncoming(payload: string): void {
|
||||
if (this.closed) return;
|
||||
if (!this.started) return;
|
||||
let message: unknown;
|
||||
try {
|
||||
message = JSON.parse(payload);
|
||||
} catch (err) {
|
||||
this.onerror?.(
|
||||
err instanceof Error ? err : new Error("Invalid JSON from WebView"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== "object") return;
|
||||
this.onmessage?.(message as JSONRPCMessage);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
const BRANDS = new Set(["claude", "gemini", "anthropic", "openai"]);
|
||||
const ACRONYMS = new Set(["gpt", "o"]);
|
||||
|
||||
// "claude-opus-5-5" reads as "Opus 5.5": no brand, version digits joined.
|
||||
export function shortModelName(id: string): string {
|
||||
const parts = (id.split("/").pop() ?? id).split("-");
|
||||
// Keep the brand when it is the only name, as in "gemini-3-pro".
|
||||
const dropBrand = BRANDS.has(parts[0]) && /^[a-z]/.test(parts[1] ?? "");
|
||||
const words = parts
|
||||
.slice(dropBrand ? 1 : 0)
|
||||
.filter((part) => !/^\d{8}$/.test(part));
|
||||
const out: string[] = [];
|
||||
for (const word of words) {
|
||||
const previous = out[out.length - 1];
|
||||
if (/^\d+$/.test(word) && previous && /^\d+(\.\d+)*$/.test(previous)) {
|
||||
out[out.length - 1] = `${previous}.${word}`;
|
||||
} else if (ACRONYMS.has(word)) {
|
||||
out.push(word.toUpperCase());
|
||||
} else {
|
||||
out.push(word.charAt(0).toUpperCase() + word.slice(1));
|
||||
}
|
||||
}
|
||||
return out.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { onlineManager } from "@tanstack/react-query";
|
||||
import * as Network from "expo-network";
|
||||
import { create } from "zustand";
|
||||
import { useSessions } from "@/lib/session";
|
||||
|
||||
export const useOnline = create<{ online: boolean }>(() => ({ online: true }));
|
||||
|
||||
// Queries pause instead of failing while offline, and live chats reconnect after.
|
||||
onlineManager.setEventListener((setOnline) => {
|
||||
const update = (state: Network.NetworkState): void => {
|
||||
const online =
|
||||
state.isConnected !== false && state.isInternetReachable !== false;
|
||||
if (online && !useOnline.getState().online) {
|
||||
useSessions.getState().reconnect();
|
||||
}
|
||||
useOnline.setState({ online });
|
||||
setOnline(online);
|
||||
};
|
||||
Network.getNetworkStateAsync()
|
||||
.then(update)
|
||||
.catch(() => {});
|
||||
const subscription = Network.addNetworkStateListener(update);
|
||||
return () => subscription.remove();
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import Constants from "expo-constants";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { authedFetch, getBaseUrl } from "@/lib/api";
|
||||
import { sessionIdentity, useAuth } from "@/lib/auth";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const log = logger.scope("notifications");
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
// The server sends `{ url: "posthog://task/<id>" }` or a legacy `{ taskId }`.
|
||||
function pathFromNotification(
|
||||
notification: Notifications.Notification,
|
||||
): string | null {
|
||||
const data = notification.request.content.data as Record<string, unknown>;
|
||||
if (typeof data.url === "string") {
|
||||
return data.url.replace(/^[a-z]+:\/\//, "/").replace(/^\/\/+/, "/");
|
||||
}
|
||||
if (typeof data.taskId === "string") return `/task/${data.taskId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchPushToken(): Promise<string | null> {
|
||||
const projectId =
|
||||
Constants.easConfig?.projectId ??
|
||||
Constants.expoConfig?.extra?.eas?.projectId;
|
||||
if (Platform.OS === "web" || !projectId) {
|
||||
if (Platform.OS !== "web")
|
||||
log.warn("Push registration unavailable: missing Expo project ID");
|
||||
return null;
|
||||
}
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
const granted =
|
||||
status === "granted" ||
|
||||
(await Notifications.requestPermissionsAsync()).status === "granted";
|
||||
if (!granted) {
|
||||
return null;
|
||||
}
|
||||
return (await Notifications.getExpoPushTokenAsync({ projectId })).data;
|
||||
}
|
||||
|
||||
let registeredToken: string | null = null;
|
||||
let pendingRegistration: { identity: string; promise: Promise<void> } | null =
|
||||
null;
|
||||
|
||||
export function registerPushToken(): Promise<void> {
|
||||
if (!useAuth.getState().session) return Promise.resolve();
|
||||
const identity = sessionIdentity();
|
||||
if (pendingRegistration?.identity === identity)
|
||||
return pendingRegistration.promise;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const token = await fetchPushToken();
|
||||
if (sessionIdentity() !== identity || !token) return;
|
||||
const response = await authedFetch(
|
||||
`${getBaseUrl()}/api/users/@me/push_tokens/`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token, platform: Platform.OS }),
|
||||
},
|
||||
);
|
||||
if (sessionIdentity() !== identity) return;
|
||||
if (!response.ok) {
|
||||
log.warn("Push token registration failed", response.status);
|
||||
return;
|
||||
}
|
||||
registeredToken = token;
|
||||
} catch {
|
||||
if (sessionIdentity() === identity)
|
||||
log.warn("Push registration failed on this device");
|
||||
}
|
||||
})().finally(() => {
|
||||
if (pendingRegistration?.promise === promise) pendingRegistration = null;
|
||||
});
|
||||
pendingRegistration = { identity, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
// After a restart the in-memory token is gone, but the server can still hold
|
||||
// this device's registration, so read the token again without prompting.
|
||||
async function currentDeviceToken(): Promise<string | null> {
|
||||
if (registeredToken) return registeredToken;
|
||||
const projectId =
|
||||
Constants.easConfig?.projectId ??
|
||||
Constants.expoConfig?.extra?.eas?.projectId;
|
||||
if (Platform.OS === "web" || !projectId) return null;
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
if (status !== "granted") return null;
|
||||
return (await Notifications.getExpoPushTokenAsync({ projectId })).data;
|
||||
}
|
||||
|
||||
// Returns false when the server may still send this account's notifications
|
||||
// to the device, so the caller can offer a retry before signing out.
|
||||
export async function unregisterPushToken(): Promise<boolean> {
|
||||
await pendingRegistration?.promise;
|
||||
try {
|
||||
const token = await currentDeviceToken();
|
||||
if (!token) return true;
|
||||
const response = await authedFetch(
|
||||
`${getBaseUrl()}/api/users/@me/push_tokens/unregister/`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
log.warn("Push token removal failed", response.status);
|
||||
return false;
|
||||
}
|
||||
registeredToken = null;
|
||||
return true;
|
||||
} catch {
|
||||
log.warn("Push token removal failed on this device");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Registers the device once a session exists and routes notification taps.
|
||||
export function usePushNotifications(): void {
|
||||
const session = useAuth((s) => s.session);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
void registerPushToken();
|
||||
const subscription = AppState.addEventListener("change", (state) => {
|
||||
if (state === "active") void registerPushToken();
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
const open = (response: Notifications.NotificationResponse): void => {
|
||||
const path = pathFromNotification(response.notification);
|
||||
if (path) router.push(path as never);
|
||||
};
|
||||
Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||
if (response) open(response);
|
||||
});
|
||||
const sub = Notifications.addNotificationResponseReceivedListener(open);
|
||||
return () => sub.remove();
|
||||
}, [router]);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as AuthSession from "expo-auth-session";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
export type CloudRegion = "us" | "eu";
|
||||
|
||||
// v1 mobile's public OAuth clients, registered for the posthog://callback scheme.
|
||||
const CLIENT_IDS: Record<CloudRegion, string> = {
|
||||
us: "a5TY7w9IjFYfes6dkPgZe6envclWw3bm2UD8ZTlm",
|
||||
eu: "1A7vO138Fh5sYmJislicN4F5HnttI6urmFttxPDU",
|
||||
};
|
||||
|
||||
export const CLOUD_HOSTS: Record<CloudRegion, string> = {
|
||||
us: "https://us.posthog.com",
|
||||
eu: "https://eu.posthog.com",
|
||||
};
|
||||
|
||||
const SCOPES = [
|
||||
"user:read",
|
||||
"user:write",
|
||||
"project:read",
|
||||
"organization:read",
|
||||
"organization:write",
|
||||
"task:write",
|
||||
"integration:read",
|
||||
"conversation:write",
|
||||
"query:read",
|
||||
"llm_skill:read",
|
||||
];
|
||||
|
||||
export interface OAuthTokens {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
scoped_teams?: number[];
|
||||
}
|
||||
|
||||
function redirectUri(): string {
|
||||
return AuthSession.makeRedirectUri({ scheme: "posthog", path: "callback" });
|
||||
}
|
||||
|
||||
async function tokenRequest(
|
||||
region: CloudRegion,
|
||||
body: Record<string, string>,
|
||||
): Promise<OAuthTokens> {
|
||||
const response = await fetch(`${CLOUD_HOSTS[region]}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...body, client_id: CLIENT_IDS[region] }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Token request failed (${response.status}): ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as OAuthTokens;
|
||||
}
|
||||
|
||||
export interface SignInOptions {
|
||||
// Open account creation first; the browser lands on the authorize page
|
||||
// afterwards, so a new user ends up signed in to the app.
|
||||
signup?: boolean;
|
||||
}
|
||||
|
||||
export async function signInWithOAuth(
|
||||
region: CloudRegion,
|
||||
{ signup = false }: SignInOptions = {},
|
||||
): Promise<OAuthTokens> {
|
||||
const host = CLOUD_HOSTS[region];
|
||||
const request = new AuthSession.AuthRequest({
|
||||
clientId: CLIENT_IDS[region],
|
||||
scopes: SCOPES,
|
||||
redirectUri: redirectUri(),
|
||||
usePKCE: true,
|
||||
extraParams: { required_access_level: "project" },
|
||||
});
|
||||
const discovery = { authorizationEndpoint: `${host}/oauth/authorize` };
|
||||
const authUrl = await request.makeAuthUrlAsync(discovery);
|
||||
const next = encodeURIComponent(authUrl.slice(host.length));
|
||||
const result = await request.promptAsync(discovery, {
|
||||
url: signup ? `${host}/signup?next=${next}` : authUrl,
|
||||
// Let users choose an account without reusing Safari's signed-in account.
|
||||
preferEphemeralSession: true,
|
||||
});
|
||||
if (result.type !== "success" || !result.params.code) {
|
||||
throw new Error(
|
||||
result.type === "error"
|
||||
? (result.error?.message ?? "Authorization failed")
|
||||
: "Sign in was cancelled",
|
||||
);
|
||||
}
|
||||
if (!request.codeVerifier) throw new Error("PKCE verifier missing");
|
||||
return tokenRequest(region, {
|
||||
grant_type: "authorization_code",
|
||||
code: result.params.code,
|
||||
redirect_uri: redirectUri(),
|
||||
code_verifier: request.codeVerifier,
|
||||
});
|
||||
}
|
||||
|
||||
export function refreshOAuth(
|
||||
region: CloudRegion,
|
||||
refreshToken: string,
|
||||
): Promise<OAuthTokens> {
|
||||
return tokenRequest(region, {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { Appearance } from "react-native";
|
||||
import { create } from "zustand";
|
||||
|
||||
const KEY = "mobilehog_prefs";
|
||||
|
||||
export type AppearanceMode = "light" | "dark" | "system";
|
||||
|
||||
interface Prefs {
|
||||
hedgehogMode: boolean;
|
||||
appearance: AppearanceMode;
|
||||
}
|
||||
|
||||
interface PrefsState extends Prefs {
|
||||
hydrate: () => Promise<void>;
|
||||
set: (patch: Partial<Prefs>) => Promise<void>;
|
||||
}
|
||||
|
||||
const defaults: Prefs = { hedgehogMode: true, appearance: "system" };
|
||||
|
||||
function applyAppearance(mode: AppearanceMode): void {
|
||||
Appearance.setColorScheme(mode === "system" ? "unspecified" : mode);
|
||||
}
|
||||
|
||||
export const usePrefs = create<PrefsState>((set, get) => ({
|
||||
...defaults,
|
||||
hydrate: async () => {
|
||||
try {
|
||||
const raw = await SecureStore.getItemAsync(KEY);
|
||||
const prefs = {
|
||||
...defaults,
|
||||
...(JSON.parse(raw ?? "{}") as Partial<Prefs>),
|
||||
};
|
||||
set(prefs);
|
||||
applyAppearance(prefs.appearance);
|
||||
} catch {}
|
||||
},
|
||||
set: async (patch) => {
|
||||
set(patch);
|
||||
const { hedgehogMode, appearance } = { ...get(), ...patch };
|
||||
applyAppearance(appearance);
|
||||
await SecureStore.setItemAsync(
|
||||
KEY,
|
||||
JSON.stringify({ hedgehogMode, appearance }),
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { authedFetch, getBaseUrl, refreshAccessTokenOnce } from "@/lib/api";
|
||||
import { requireSession, useAuth } from "@/lib/auth";
|
||||
|
||||
export interface ProjectOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
async function loadProject(projectId: number): Promise<ProjectOption | null> {
|
||||
const response = await authedFetch(
|
||||
`${getBaseUrl()}/api/projects/${projectId}/`,
|
||||
);
|
||||
if (response.status === 403 || response.status === 404) return null;
|
||||
if (!response.ok) throw new Error("Could not load the project. Try again.");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadProjects(): Promise<ProjectOption[]> {
|
||||
// Sessions from before scoped teams were stored learn them on the next refresh.
|
||||
if (requireSession().refreshToken && !requireSession().scopedTeams) {
|
||||
await refreshAccessTokenOnce();
|
||||
}
|
||||
const { scopedTeams } = requireSession();
|
||||
// A scoped token can reach projects outside the current organization.
|
||||
if (scopedTeams?.length) {
|
||||
const projects = await Promise.all(scopedTeams.map(loadProject));
|
||||
return projects.filter((project): project is ProjectOption => !!project);
|
||||
}
|
||||
const projects: ProjectOption[] = [];
|
||||
let hasNext = true;
|
||||
while (hasNext) {
|
||||
const response = await authedFetch(
|
||||
`${getBaseUrl()}/api/projects/?limit=100&offset=${projects.length}`,
|
||||
);
|
||||
if (!response.ok) throw new Error("Could not load projects. Try again.");
|
||||
const page = (await response.json()) as {
|
||||
results: ProjectOption[];
|
||||
next: string | null;
|
||||
};
|
||||
projects.push(...page.results);
|
||||
hasNext = !!page.next && page.results.length > 0;
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
|
||||
export function useProjects() {
|
||||
const session = useAuth((s) => s.session);
|
||||
return useQuery({
|
||||
queryKey: ["projects", session?.host, session?.userId],
|
||||
queryFn: loadProjects,
|
||||
enabled: !!session,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { GatewayModel } from "@posthog/shared";
|
||||
import type { Task } from "@posthog/shared/domain-types";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { DEFAULT_MODEL, DEFAULT_REPOSITORY } from "@/config";
|
||||
import { type Photo, uploadStagedPhotos } from "@/lib/attachments";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { getClient } from "@/lib/client";
|
||||
import { currentRunConfig } from "@/lib/composer";
|
||||
import { useRepo } from "@/lib/repo";
|
||||
|
||||
const TERMINAL: ReadonlySet<string> = new Set([
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export const keys = {
|
||||
tasks: ["tasks"] as const,
|
||||
task: (id: string) => ["tasks", id] as const,
|
||||
models: ["models"] as const,
|
||||
repository: ["repository"] as const,
|
||||
repositories: ["repositories"] as const,
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function visibleTasks(tasks: Task[]): Task[] {
|
||||
return tasks.filter(
|
||||
(task) =>
|
||||
!task.internal &&
|
||||
task.latest_run?.environment !== "local" &&
|
||||
!task.origin_key?.startsWith("desktop_onboarding"),
|
||||
);
|
||||
}
|
||||
|
||||
// The full task history, newest activity first, one page at a time.
|
||||
export function useTaskPages(search: string, archived: boolean) {
|
||||
const session = useAuth((s) => s.session);
|
||||
const scope = archived ? "archived" : "active";
|
||||
return useInfiniteQuery({
|
||||
queryKey: search
|
||||
? [...keys.tasks, "pages", scope, "search", search]
|
||||
: [...keys.tasks, "pages", scope],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const page = await getClient().getTasksPage({
|
||||
basic: true,
|
||||
archived,
|
||||
search: search || undefined,
|
||||
ordering: "-last_activity_at",
|
||||
limit: PAGE_SIZE,
|
||||
offset: pageParam,
|
||||
});
|
||||
return { ...page, visible: visibleTasks(page.tasks) };
|
||||
},
|
||||
getNextPageParam: (page, _pages, offset) => {
|
||||
const next = offset + page.tasks.length;
|
||||
return page.tasks.length > 0 && next < page.count ? next : undefined;
|
||||
},
|
||||
enabled: !!session,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTasks(search = "") {
|
||||
const session = useAuth((s) => s.session);
|
||||
return useQuery({
|
||||
queryKey: search ? [...keys.tasks, "search", search] : keys.tasks,
|
||||
queryFn: async () =>
|
||||
visibleTasks(
|
||||
await getClient().getTasks({
|
||||
basic: true,
|
||||
search: search || undefined,
|
||||
}),
|
||||
),
|
||||
enabled: !!session,
|
||||
refetchInterval: (query) => {
|
||||
const tasks = query.state.data as Task[] | undefined;
|
||||
const anyLive = tasks?.some((task) => {
|
||||
const status = task.latest_run?.status;
|
||||
return !!status && !TERMINAL.has(status);
|
||||
});
|
||||
return anyLive ? 5000 : 30000;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTask(taskId: string) {
|
||||
const session = useAuth((s) => s.session);
|
||||
return useQuery({
|
||||
queryKey: keys.task(taskId),
|
||||
queryFn: () => getClient().getTask(taskId),
|
||||
enabled: !!session && !!taskId,
|
||||
refetchInterval: (query) => {
|
||||
const status = (query.state.data as Task | undefined)?.latest_run?.status;
|
||||
return status && !TERMINAL.has(status) ? 5000 : false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// The gateway may not be running locally, so the pill always has a default.
|
||||
export function useModels() {
|
||||
const session = useAuth((s) => s.session);
|
||||
return useQuery<GatewayModel[]>({
|
||||
queryKey: keys.models,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const models = await getClient().getCloudTaskGatewayModels();
|
||||
const allowed = models.filter((model) => model.allowed);
|
||||
return allowed.length > 0 ? allowed : fallbackModels();
|
||||
} catch {
|
||||
return fallbackModels();
|
||||
}
|
||||
},
|
||||
enabled: !!session,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function fallbackModels(): GatewayModel[] {
|
||||
return [
|
||||
{
|
||||
id: DEFAULT_MODEL,
|
||||
owned_by: "anthropic",
|
||||
context_window: 200_000,
|
||||
supports_streaming: true,
|
||||
supports_vision: true,
|
||||
allowed: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Most recently used repository on this project, else the configured default.
|
||||
// The chosen repo wins, then the config default, then the most recently used.
|
||||
export function useDefaultRepository() {
|
||||
const tasks = useTasks();
|
||||
const chosen = useRepo((s) => s.repository);
|
||||
const recent = tasks.data?.find((task) => task.repository)?.repository;
|
||||
return {
|
||||
data:
|
||||
chosen !== undefined ? chosen : (DEFAULT_REPOSITORY ?? recent ?? null),
|
||||
isLoading: chosen === undefined && tasks.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRepositories() {
|
||||
const session = useAuth((s) => s.session);
|
||||
return useQuery<string[]>({
|
||||
queryKey: keys.repositories,
|
||||
queryFn: async () => {
|
||||
const client = getClient();
|
||||
const integrations = await client.getGithubUserIntegrations();
|
||||
const lists = await Promise.all(
|
||||
integrations.map((integration) =>
|
||||
client.getGithubUserRepositories(integration.installation_id),
|
||||
),
|
||||
);
|
||||
return [...new Set(lists.flat())].sort();
|
||||
},
|
||||
enabled: !!session,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTask() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
...patch
|
||||
}: {
|
||||
id: string;
|
||||
title?: string;
|
||||
archived?: boolean;
|
||||
}) => getClient().updateTask(id, patch),
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: keys.tasks }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvalidateTasks() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.invalidateQueries({ queryKey: keys.tasks });
|
||||
}
|
||||
|
||||
export async function createAndRunTask(input: {
|
||||
prompt: string;
|
||||
repository: string | null;
|
||||
photos?: Photo[];
|
||||
}): Promise<Task> {
|
||||
const client = getClient();
|
||||
const task = await client.createTask({
|
||||
description: input.prompt,
|
||||
title: input.prompt.slice(0, 100),
|
||||
repository: input.repository ?? undefined,
|
||||
});
|
||||
const artifactIds = await uploadStagedPhotos(task.id, input.photos ?? []);
|
||||
return client.runTaskInCloud(task.id, undefined, {
|
||||
pendingUserMessage: input.prompt,
|
||||
pendingUserArtifactIds: artifactIds.length ? artifactIds : undefined,
|
||||
...currentRunConfig(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { create } from "zustand";
|
||||
import { accountStorageKey, sessionIdentity, useAuth } from "@/lib/auth";
|
||||
|
||||
const KEY = "mobilehog_repository";
|
||||
|
||||
interface RepoState {
|
||||
// undefined = not chosen yet, null = explicitly none.
|
||||
repository: string | null | undefined;
|
||||
hydrate: () => Promise<void>;
|
||||
setRepository: (repository: string | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useRepo = create<RepoState>((set) => ({
|
||||
repository: undefined,
|
||||
hydrate: async () => {
|
||||
if (!useAuth.getState().session) return;
|
||||
const identity = sessionIdentity();
|
||||
try {
|
||||
const raw = await SecureStore.getItemAsync(accountStorageKey(KEY));
|
||||
if (sessionIdentity() !== identity) return;
|
||||
set({ repository: raw === null ? undefined : raw === "" ? null : raw });
|
||||
} catch {
|
||||
if (sessionIdentity() === identity) set({ repository: undefined });
|
||||
}
|
||||
},
|
||||
setRepository: async (repository) => {
|
||||
set({ repository });
|
||||
await SecureStore.setItemAsync(accountStorageKey(KEY), repository ?? "");
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
buildCreatePrReportPrompt,
|
||||
canCreateImplementationPr,
|
||||
} from "@posthog/core/inbox/reportActions";
|
||||
import {
|
||||
INBOX_ACTIONABLE_ACTIONABILITY_FILTER,
|
||||
INBOX_ACTIONABLE_REPORT_STATUS_FILTER,
|
||||
} from "@posthog/core/inbox/reportFiltering";
|
||||
import type {
|
||||
SignalReport,
|
||||
SignalReportArtefactsResponse,
|
||||
SignalReportSignalsResponse,
|
||||
} from "@posthog/shared/domain-types";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { create } from "zustand";
|
||||
import { accountStorageKey, sessionIdentity, useAuth } from "@/lib/auth";
|
||||
import { getClient } from "@/lib/client";
|
||||
import { currentRunConfig } from "@/lib/composer";
|
||||
|
||||
export const reportKeys = {
|
||||
all: ["reports"] as const,
|
||||
list: ["reports", "list"] as const,
|
||||
signals: (id: string) => ["reports", id, "signals"] as const,
|
||||
artefacts: (id: string) => ["reports", id, "artefacts"] as const,
|
||||
};
|
||||
|
||||
// Reports a person can act on right now, highest priority first.
|
||||
export function useReports(search = "") {
|
||||
const session = useAuth((s) => s.session);
|
||||
return useQuery({
|
||||
queryKey: search ? [...reportKeys.list, "search", search] : reportKeys.list,
|
||||
queryFn: () =>
|
||||
getClient().getSignalReports({
|
||||
status: INBOX_ACTIONABLE_REPORT_STATUS_FILTER,
|
||||
actionability: INBOX_ACTIONABLE_ACTIONABILITY_FILTER,
|
||||
ordering: "status,-priority,-created_at",
|
||||
limit: 100,
|
||||
search: search || undefined,
|
||||
}),
|
||||
enabled: !!session,
|
||||
refetchInterval: 60_000,
|
||||
select: (page) =>
|
||||
page.results.filter((report) => canCreateImplementationPr(report)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReportSignals(reportId: string | null) {
|
||||
return useQuery<SignalReportSignalsResponse>({
|
||||
queryKey: reportKeys.signals(reportId ?? ""),
|
||||
queryFn: () => getClient().getSignalReportSignals(reportId ?? ""),
|
||||
enabled: !!reportId,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useReportArtefacts(reportId: string | null) {
|
||||
return useQuery<SignalReportArtefactsResponse>({
|
||||
queryKey: reportKeys.artefacts(reportId ?? ""),
|
||||
queryFn: () => getClient().getSignalReportArtefacts(reportId ?? ""),
|
||||
enabled: !!reportId,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDismissReport() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (reportId: string) =>
|
||||
getClient().updateSignalReportState(reportId, { state: "suppressed" }),
|
||||
onSettled: () =>
|
||||
queryClient.invalidateQueries({ queryKey: reportKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
// Tasks created for a report whose run failed to start. A retry reuses the
|
||||
// task so the report does not collect duplicate implementation tasks.
|
||||
const unstartedTasks = new Map<string, string>();
|
||||
|
||||
export function resetUnstartedReportTasks(): void {
|
||||
unstartedTasks.clear();
|
||||
}
|
||||
|
||||
// Swipe right: open a task on the report's repo and start the agent on it.
|
||||
export function useStartReport() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (report: SignalReport) => {
|
||||
const client = getClient();
|
||||
const prompt = buildCreatePrReportPrompt({ reportId: report.id });
|
||||
let taskId = unstartedTasks.get(report.id);
|
||||
if (!taskId) {
|
||||
// The server picks the repository from the report's repo selection.
|
||||
const task = await client.createTask({
|
||||
description: prompt,
|
||||
title: (report.title ?? "Signal report").slice(0, 255),
|
||||
origin_product: "signal_report",
|
||||
signal_report: report.id,
|
||||
signal_report_task_relationship: "implementation",
|
||||
});
|
||||
taskId = task.id;
|
||||
unstartedTasks.set(report.id, taskId);
|
||||
}
|
||||
const started = await client.runTaskInCloud(taskId, undefined, {
|
||||
pendingUserMessage: prompt,
|
||||
runSource: "signal_report",
|
||||
signalReportId: report.id,
|
||||
...currentRunConfig(),
|
||||
});
|
||||
unstartedTasks.delete(report.id);
|
||||
return started;
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: reportKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Which reports this device has already surfaced in triage, so only new ones
|
||||
// pop the deck automatically.
|
||||
const SEEN_KEY = "mobilehog_seen_reports";
|
||||
const SEEN_CAP = 500;
|
||||
|
||||
interface SeenState {
|
||||
seen: Set<string>;
|
||||
hydrated: boolean;
|
||||
hydrate: () => Promise<void>;
|
||||
markSeen: (ids: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSeenReports = create<SeenState>((set, get) => ({
|
||||
seen: new Set(),
|
||||
hydrated: false,
|
||||
hydrate: async () => {
|
||||
if (!useAuth.getState().session) return;
|
||||
const identity = sessionIdentity();
|
||||
try {
|
||||
const raw = await SecureStore.getItemAsync(accountStorageKey(SEEN_KEY));
|
||||
if (sessionIdentity() !== identity) return;
|
||||
set({
|
||||
seen: new Set(raw ? (JSON.parse(raw) as string[]) : []),
|
||||
hydrated: true,
|
||||
});
|
||||
} catch {
|
||||
if (sessionIdentity() === identity) set({ hydrated: true });
|
||||
}
|
||||
},
|
||||
markSeen: async (ids) => {
|
||||
const next = new Set(get().seen);
|
||||
for (const id of ids) next.add(id);
|
||||
const list = [...next].slice(-SEEN_CAP);
|
||||
set({ seen: new Set(list) });
|
||||
await SecureStore.setItemAsync(
|
||||
accountStorageKey(SEEN_KEY),
|
||||
JSON.stringify(list),
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,502 @@
|
||||
import { CloudCommandError } from "@posthog/api-client/posthog-client";
|
||||
import type {
|
||||
CloudTaskUpdatePayload,
|
||||
Task,
|
||||
TaskRunStatus,
|
||||
} from "@posthog/shared";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { create } from "zustand";
|
||||
import { type Photo, uploadRunPhotos } from "@/lib/attachments";
|
||||
import { loadTranscript, saveTranscript } from "@/lib/cache";
|
||||
import { getClient } from "@/lib/client";
|
||||
import { currentRunConfig } from "@/lib/composer";
|
||||
import { type WatchHandle, watchRun } from "@/lib/engine";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
type Block,
|
||||
closeOpenAgent,
|
||||
foldEntries,
|
||||
type PermissionRequest,
|
||||
} from "@/lib/transcript";
|
||||
|
||||
const log = logger.scope("session");
|
||||
|
||||
const TERMINAL: ReadonlySet<string> = new Set([
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export interface TaskSession {
|
||||
taskId: string;
|
||||
runId: string;
|
||||
blocks: Block[];
|
||||
runStatus: TaskRunStatus | null;
|
||||
stage: string | null;
|
||||
connected: boolean;
|
||||
turnActive: boolean;
|
||||
awaitingInput: boolean;
|
||||
error: string | null;
|
||||
permissions: Record<string, PermissionRequest>;
|
||||
// Prompts sent from this device, so their echo in the log is not shown twice.
|
||||
localEchoes: Set<string>;
|
||||
// Last prompt sent, kept so a dead sandbox can be resumed with it.
|
||||
lastPrompt: string | null;
|
||||
// A replacement run is being created for a finished one.
|
||||
resuming: boolean;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
sessions: Record<string, TaskSession>;
|
||||
reset: () => void;
|
||||
// A chat that exists on screen before its task does. `adopt` moves it under
|
||||
// the real task id once the run is created; `fail` leaves the prompt with an error.
|
||||
startPending: (
|
||||
tempId: string,
|
||||
prompt: string,
|
||||
localId: string,
|
||||
images?: string[],
|
||||
) => void;
|
||||
adopt: (tempId: string, task: Task) => void;
|
||||
failPending: (tempId: string, message: string) => void;
|
||||
connect: (task: Task) => void;
|
||||
disconnect: (taskId: string) => void;
|
||||
reconnect: () => void;
|
||||
sendPrompt: (
|
||||
taskId: string,
|
||||
text: string,
|
||||
localId?: string,
|
||||
photos?: Photo[],
|
||||
) => Promise<string | null>;
|
||||
cancelTurn: (taskId: string) => Promise<void>;
|
||||
stopRun: (taskId: string) => Promise<void>;
|
||||
respondToPermission: (
|
||||
taskId: string,
|
||||
toolCallId: string,
|
||||
optionId: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const handles = new Map<string, WatchHandle>();
|
||||
|
||||
// A finished run answers commands with 409 "workflow has ended"; a dead
|
||||
// sandbox with 404. Both mean: start a replacement run carrying the message.
|
||||
function runIsGone(error: CloudCommandError): boolean {
|
||||
return (
|
||||
error.isSandboxInactive() ||
|
||||
error.status === 409 ||
|
||||
!!error.backendError?.toLowerCase().includes("workflow has ended")
|
||||
);
|
||||
}
|
||||
|
||||
function emptySession(taskId: string, runId: string): TaskSession {
|
||||
return {
|
||||
taskId,
|
||||
runId,
|
||||
blocks: [],
|
||||
runStatus: null,
|
||||
stage: null,
|
||||
connected: false,
|
||||
turnActive: false,
|
||||
awaitingInput: false,
|
||||
error: null,
|
||||
permissions: {},
|
||||
localEchoes: new Set(),
|
||||
lastPrompt: null,
|
||||
resuming: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const useSessions = create<SessionState>((set, get) => {
|
||||
let generation = 0;
|
||||
const patch = (
|
||||
taskId: string,
|
||||
fn: (s: TaskSession) => Partial<TaskSession>,
|
||||
): void => {
|
||||
set((state) => {
|
||||
const current = state.sessions[taskId];
|
||||
if (!current) return state;
|
||||
return {
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[taskId]: { ...current, ...fn(current) },
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const applyUpdate = (
|
||||
taskId: string,
|
||||
update: CloudTaskUpdatePayload,
|
||||
): void => {
|
||||
const session = get().sessions[taskId];
|
||||
if (!session || session.runId !== update.runId) return;
|
||||
|
||||
if (update.kind === "error") {
|
||||
patch(taskId, () => ({ error: update.errorMessage, connected: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.kind === "permission_request") {
|
||||
const toolCallId = update.toolCall?.toolCallId;
|
||||
if (!toolCallId) return;
|
||||
patch(taskId, (s) => ({
|
||||
permissions: {
|
||||
...s.permissions,
|
||||
[toolCallId]: {
|
||||
requestId: update.requestId,
|
||||
toolCallId,
|
||||
title: update.toolCall.title,
|
||||
input: update.toolCall.rawInput,
|
||||
options: update.options,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(
|
||||
() => {},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.kind === "status") {
|
||||
patch(taskId, (s) => {
|
||||
const terminal = !!update.status && TERMINAL.has(update.status);
|
||||
return {
|
||||
runStatus: update.status ?? s.runStatus,
|
||||
stage: update.stage ?? s.stage,
|
||||
turnActive: terminal ? false : s.turnActive,
|
||||
error: update.errorMessage ?? s.error,
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isSnapshot = update.kind === "snapshot";
|
||||
const echoes = isSnapshot ? new Set<string>() : session.localEchoes;
|
||||
const folded = foldEntries(
|
||||
isSnapshot ? [] : session.blocks,
|
||||
update.newEntries,
|
||||
echoes,
|
||||
);
|
||||
|
||||
patch(taskId, (s) => {
|
||||
const permissions = isSnapshot ? {} : { ...s.permissions };
|
||||
for (const request of folded.permissionRequests) {
|
||||
permissions[request.toolCallId] = request;
|
||||
}
|
||||
for (const requestId of folded.resolvedRequestIds) {
|
||||
for (const [key, value] of Object.entries(permissions)) {
|
||||
if (value.requestId === requestId) delete permissions[key];
|
||||
}
|
||||
}
|
||||
// A tool call that already finished no longer needs an answer.
|
||||
for (const block of folded.blocks) {
|
||||
if (
|
||||
block.kind === "tool" &&
|
||||
block.status !== "pending" &&
|
||||
permissions[block.id]
|
||||
) {
|
||||
delete permissions[block.id];
|
||||
}
|
||||
}
|
||||
|
||||
let turnActive = s.turnActive;
|
||||
if (folded.externalUserMessages > 0) turnActive = true;
|
||||
if (folded.turnEnded) turnActive = false;
|
||||
const status = isSnapshot ? (update.status ?? s.runStatus) : s.runStatus;
|
||||
if (status && TERMINAL.has(status)) turnActive = false;
|
||||
// A reopened live run with an unfinished agent block is still working.
|
||||
if (isSnapshot && status && !TERMINAL.has(status)) {
|
||||
const tail = folded.blocks[folded.blocks.length - 1];
|
||||
turnActive =
|
||||
tail?.kind === "user" ||
|
||||
(tail?.kind === "agent" && !tail.complete) ||
|
||||
tail?.kind === "tool";
|
||||
}
|
||||
|
||||
return {
|
||||
blocks: folded.blocks,
|
||||
connected: true,
|
||||
turnActive,
|
||||
awaitingInput: folded.awaitingInput,
|
||||
error:
|
||||
folded.errorMessage ??
|
||||
(isSnapshot ? (update.errorMessage ?? null) : s.error),
|
||||
permissions,
|
||||
localEchoes: echoes,
|
||||
runStatus: status ?? s.runStatus,
|
||||
stage: isSnapshot ? (update.stage ?? s.stage) : s.stage,
|
||||
};
|
||||
});
|
||||
|
||||
if (isSnapshot || folded.turnEnded) saveTranscript(taskId, folded.blocks);
|
||||
|
||||
if (!isSnapshot && folded.turnEnded && session.turnActive) {
|
||||
Haptics.notificationAsync(
|
||||
folded.turnFailed
|
||||
? Haptics.NotificationFeedbackType.Error
|
||||
: Haptics.NotificationFeedbackType.Success,
|
||||
).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const watch = (taskId: string, runId: string): void => {
|
||||
const currentGeneration = generation;
|
||||
handles.get(taskId)?.stop();
|
||||
handles.set(
|
||||
taskId,
|
||||
watchRun(taskId, runId, (update) => {
|
||||
if (generation === currentGeneration) applyUpdate(taskId, update);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const resumeRun = async (taskId: string, prompt: string): Promise<void> => {
|
||||
const currentGeneration = generation;
|
||||
const session = get().sessions[taskId];
|
||||
if (!session) return;
|
||||
log.info("Sandbox gone, resuming run", {
|
||||
taskId,
|
||||
previousRunId: session.runId,
|
||||
});
|
||||
const task = await getClient().runTaskInCloud(taskId, undefined, {
|
||||
resumeFromRunId: session.runId,
|
||||
pendingUserMessage: prompt,
|
||||
...currentRunConfig(),
|
||||
});
|
||||
if (generation !== currentGeneration) return;
|
||||
const runId = task.latest_run?.id;
|
||||
if (!runId) throw new Error("Resume did not return a run");
|
||||
set((state) => ({
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[taskId]: {
|
||||
...emptySession(taskId, runId),
|
||||
blocks: state.sessions[taskId]?.blocks ?? [],
|
||||
turnActive: true,
|
||||
lastPrompt: prompt,
|
||||
resuming: true,
|
||||
},
|
||||
},
|
||||
}));
|
||||
watch(taskId, runId);
|
||||
};
|
||||
|
||||
return {
|
||||
sessions: {},
|
||||
|
||||
reset: () => {
|
||||
generation += 1;
|
||||
for (const handle of handles.values()) handle.stop();
|
||||
handles.clear();
|
||||
set({ sessions: {} });
|
||||
},
|
||||
|
||||
startPending: (tempId, prompt, localId, images) => {
|
||||
set((state) => ({
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[tempId]: {
|
||||
...emptySession(tempId, ""),
|
||||
blocks: [{ kind: "user", id: localId, text: prompt, images }],
|
||||
runStatus: "queued",
|
||||
turnActive: true,
|
||||
localEchoes: new Set([prompt]),
|
||||
lastPrompt: prompt,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
adopt: (tempId, task) => {
|
||||
const runId = task.latest_run?.id;
|
||||
const pending = get().sessions[tempId];
|
||||
if (!runId || !pending) return;
|
||||
set((state) => {
|
||||
const sessions = { ...state.sessions };
|
||||
delete sessions[tempId];
|
||||
sessions[task.id] = {
|
||||
...pending,
|
||||
taskId: task.id,
|
||||
runId,
|
||||
runStatus: task.latest_run?.status ?? "queued",
|
||||
};
|
||||
return { sessions };
|
||||
});
|
||||
watch(task.id, runId);
|
||||
},
|
||||
|
||||
failPending: (tempId, message) => {
|
||||
patch(tempId, () => ({ turnActive: false, error: message }));
|
||||
},
|
||||
|
||||
connect: (task) => {
|
||||
const runId = task.latest_run?.id;
|
||||
if (!runId) return;
|
||||
const existing = get().sessions[task.id];
|
||||
if (existing?.runId === runId && handles.has(task.id)) return;
|
||||
set((state) => ({
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
// The saved chat shows at once; the live snapshot replaces it.
|
||||
[task.id]: {
|
||||
...emptySession(task.id, runId),
|
||||
blocks: loadTranscript(task.id) ?? [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
watch(task.id, runId);
|
||||
},
|
||||
|
||||
disconnect: (taskId) => {
|
||||
const session = get().sessions[taskId];
|
||||
if (session) saveTranscript(taskId, session.blocks);
|
||||
handles.get(taskId)?.stop();
|
||||
handles.delete(taskId);
|
||||
},
|
||||
|
||||
reconnect: () => {
|
||||
for (const handle of handles.values()) handle.reconnectIfDisconnected();
|
||||
},
|
||||
|
||||
sendPrompt: async (
|
||||
taskId,
|
||||
text,
|
||||
localId = `local-${Date.now()}`,
|
||||
photos = [],
|
||||
) => {
|
||||
const currentGeneration = generation;
|
||||
const session = get().sessions[taskId];
|
||||
if (!session) return null;
|
||||
const echoes = new Set(session.localEchoes);
|
||||
echoes.add(text);
|
||||
patch(taskId, (s) => {
|
||||
const blocks = [...s.blocks];
|
||||
closeOpenAgent(blocks);
|
||||
blocks.push({
|
||||
kind: "user",
|
||||
id: localId,
|
||||
text,
|
||||
images: photos.length ? photos.map((photo) => photo.uri) : undefined,
|
||||
});
|
||||
return {
|
||||
blocks,
|
||||
turnActive: true,
|
||||
awaitingInput: false,
|
||||
error: null,
|
||||
localEchoes: echoes,
|
||||
lastPrompt: text,
|
||||
};
|
||||
});
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
||||
try {
|
||||
const artifactIds = await uploadRunPhotos(
|
||||
taskId,
|
||||
session.runId,
|
||||
photos,
|
||||
);
|
||||
await getClient().sendCloudRunCommand(
|
||||
taskId,
|
||||
session.runId,
|
||||
"user_message",
|
||||
artifactIds.length
|
||||
? { content: text, artifact_ids: artifactIds }
|
||||
: { content: text },
|
||||
);
|
||||
} catch (error) {
|
||||
if (generation !== currentGeneration) return null;
|
||||
if (error instanceof CloudCommandError && runIsGone(error)) {
|
||||
patch(taskId, () => ({ resuming: true }));
|
||||
try {
|
||||
await resumeRun(taskId, text);
|
||||
} catch (resumeError) {
|
||||
if (generation !== currentGeneration) return null;
|
||||
patch(taskId, (s) => ({
|
||||
blocks: s.blocks.filter((block) => block.id !== localId),
|
||||
resuming: false,
|
||||
turnActive: false,
|
||||
error:
|
||||
resumeError instanceof Error
|
||||
? resumeError.message
|
||||
: String(resumeError),
|
||||
}));
|
||||
throw resumeError;
|
||||
}
|
||||
return localId;
|
||||
}
|
||||
echoes.delete(text);
|
||||
// The composer gets the draft back, so drop the unsent bubble.
|
||||
patch(taskId, (s) => ({
|
||||
blocks: s.blocks.filter((block) => block.id !== localId),
|
||||
turnActive: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
return localId;
|
||||
},
|
||||
|
||||
cancelTurn: async (taskId) => {
|
||||
const currentGeneration = generation;
|
||||
const session = get().sessions[taskId];
|
||||
if (!session) return;
|
||||
try {
|
||||
await getClient().sendCloudRunCommand(taskId, session.runId, "cancel");
|
||||
if (generation !== currentGeneration) return;
|
||||
patch(taskId, () => ({ turnActive: false }));
|
||||
} catch (error) {
|
||||
if (generation !== currentGeneration) return;
|
||||
patch(taskId, () => ({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
stopRun: async (taskId) => {
|
||||
const currentGeneration = generation;
|
||||
const session = get().sessions[taskId];
|
||||
if (!session) return;
|
||||
await getClient().cancelTaskRun(taskId, session.runId);
|
||||
if (generation !== currentGeneration) return;
|
||||
patch(taskId, () => ({ turnActive: false, runStatus: "cancelled" }));
|
||||
},
|
||||
|
||||
respondToPermission: async (taskId, toolCallId, optionId) => {
|
||||
const currentGeneration = generation;
|
||||
const session = get().sessions[taskId];
|
||||
const request = session?.permissions[toolCallId];
|
||||
if (!session || !request) return;
|
||||
patch(taskId, (s) => ({
|
||||
permissions: {
|
||||
...s.permissions,
|
||||
[toolCallId]: { ...request, chosenOptionId: optionId },
|
||||
},
|
||||
turnActive: true,
|
||||
}));
|
||||
try {
|
||||
await getClient().sendCloudRunCommand(
|
||||
taskId,
|
||||
session.runId,
|
||||
"permission_response",
|
||||
{
|
||||
requestId: request.requestId,
|
||||
toolCallId,
|
||||
optionId,
|
||||
},
|
||||
);
|
||||
if (generation !== currentGeneration) return;
|
||||
patch(taskId, (s) => {
|
||||
const permissions = { ...s.permissions };
|
||||
delete permissions[toolCallId];
|
||||
return { permissions };
|
||||
});
|
||||
} catch (error) {
|
||||
if (generation !== currentGeneration) return;
|
||||
patch(taskId, (s) => ({
|
||||
permissions: { ...s.permissions, [toolCallId]: request },
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { colors as brand } from "@posthog/brand/colors";
|
||||
import { DynamicColorIOS } from "react-native";
|
||||
|
||||
// Every token resolves per trait collection, so static stylesheets follow the
|
||||
// scheme without re-rendering. Light is posthog.com's tan and ink; dark inverts.
|
||||
const dyn = (light: string, dark: string) => DynamicColorIOS({ light, dark });
|
||||
|
||||
export const colors = {
|
||||
bg: dyn("#EEEFE9", "#151515"),
|
||||
bgDeep: dyn("#E4E6DE", "#0C0C0C"),
|
||||
// Raised surfaces: a half step between the page and white.
|
||||
bgRaised: dyn("#F7F7F3", "#1C1C1C"),
|
||||
surface: dyn("#FFFFFF", "#222222"),
|
||||
ink: dyn("#151515", "#EEEFE9"),
|
||||
inkSoft: dyn("#4F5150", "#B8BAB4"),
|
||||
inkMute: dyn("#8F918D", "#7C7E7A"),
|
||||
line: dyn("rgba(21, 21, 21, 0.08)", "rgba(238, 239, 233, 0.10)"),
|
||||
fill: dyn("rgba(21, 21, 21, 0.06)", "rgba(238, 239, 233, 0.08)"),
|
||||
code: dyn("rgba(21, 21, 21, 0.05)", "rgba(238, 239, 233, 0.07)"),
|
||||
glass: dyn("rgba(255, 255, 255, 0.55)", "rgba(40, 40, 40, 0.55)"),
|
||||
glassTint: dyn("rgba(255, 255, 255, 0.75)", "rgba(34, 34, 34, 0.75)"),
|
||||
sceneTint: dyn("rgba(238, 239, 233, 0.72)", "rgba(21, 21, 21, 0.72)"),
|
||||
accent: brand.tangerine.core,
|
||||
danger: brand.coral.core,
|
||||
ok: brand.green.core,
|
||||
// High-contrast buttons: ink on tan, tan on ink.
|
||||
dark: dyn("#151515", "#EEEFE9"),
|
||||
darkText: dyn("#EEEFE9", "#151515"),
|
||||
};
|
||||
|
||||
// RoundHog is the brand face; each weight is its own registered family.
|
||||
export const fonts = {
|
||||
sans: "RoundHog",
|
||||
sansMedium: "RoundHog-Medium",
|
||||
sansSemi: "RoundHog-SemiBold",
|
||||
sansBold: "RoundHog-Bold",
|
||||
sansItalic: "RoundHog-Italic",
|
||||
serif: "RoundHog-SemiBold",
|
||||
mono: "JetBrainsMono-Regular",
|
||||
monoMedium: "JetBrainsMono-Medium",
|
||||
};
|
||||
|
||||
// Shared by the drawer layout and the shadow ghost behind the chat layer.
|
||||
export const drawer = {
|
||||
widthFraction: 0.82,
|
||||
sceneRadius: 44,
|
||||
};
|
||||
|
||||
export const radius = {
|
||||
pill: 999,
|
||||
card: 24,
|
||||
bubble: 18,
|
||||
};
|
||||
@@ -0,0 +1,313 @@
|
||||
import type { StoredLogEntry } from "@posthog/shared";
|
||||
import { isIdleResumeTurnComplete } from "@posthog/shared";
|
||||
|
||||
export type ToolStatus = "pending" | "running" | "completed" | "failed";
|
||||
|
||||
export interface PlanEntry {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed" | "failed";
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
export type Block =
|
||||
// images: local photo URIs, shown until the chat reloads.
|
||||
| { kind: "user"; id: string; text: string; images?: string[] }
|
||||
| { kind: "agent"; id: string; text: string; complete: boolean }
|
||||
| { kind: "thought"; id: string; text: string; at: number }
|
||||
| {
|
||||
kind: "tool";
|
||||
id: string;
|
||||
title: string;
|
||||
toolName?: string;
|
||||
status: ToolStatus;
|
||||
input?: Record<string, unknown>;
|
||||
output?: unknown;
|
||||
parentId?: string;
|
||||
// Epoch ms of the first and latest log entries for this call.
|
||||
at: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
| { kind: "plan"; id: string; entries: PlanEntry[] };
|
||||
|
||||
interface SessionUpdate {
|
||||
sessionUpdate?: string;
|
||||
content?: { type: string; text: string };
|
||||
title?: string;
|
||||
toolCallId?: string;
|
||||
status?: "pending" | "in_progress" | "completed" | "failed" | null;
|
||||
rawInput?: Record<string, unknown>;
|
||||
rawOutput?: unknown;
|
||||
entries?: PlanEntry[];
|
||||
_meta?: { claudeCode?: { toolName?: string; parentToolCallId?: string } };
|
||||
}
|
||||
|
||||
export interface PermissionOption {
|
||||
kind: string;
|
||||
optionId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequest {
|
||||
requestId: string;
|
||||
toolCallId: string;
|
||||
title: string;
|
||||
input?: Record<string, unknown>;
|
||||
options: PermissionOption[];
|
||||
chosenOptionId?: string;
|
||||
}
|
||||
|
||||
export interface FoldResult {
|
||||
blocks: Block[];
|
||||
// Signals for turn state, so the store does not re-walk entries.
|
||||
turnEnded: boolean;
|
||||
turnFailed: boolean;
|
||||
awaitingInput: boolean;
|
||||
externalUserMessages: number;
|
||||
// Permission requests replayed from the log, for reopened tasks.
|
||||
permissionRequests: PermissionRequest[];
|
||||
resolvedRequestIds: string[];
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
function nextId(prefix: string): string {
|
||||
counter += 1;
|
||||
return `${prefix}-${counter}`;
|
||||
}
|
||||
|
||||
function mapToolStatus(status: SessionUpdate["status"]): ToolStatus {
|
||||
switch (status) {
|
||||
case "in_progress":
|
||||
return "running";
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "failed":
|
||||
return "failed";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
function entryTime(entry: StoredLogEntry): number {
|
||||
const parsed = entry.timestamp ? Date.parse(entry.timestamp) : Number.NaN;
|
||||
return Number.isFinite(parsed) ? parsed : Date.now();
|
||||
}
|
||||
|
||||
function last(blocks: Block[]): Block | undefined {
|
||||
return blocks[blocks.length - 1];
|
||||
}
|
||||
|
||||
export function closeOpenAgent(blocks: Block[]): void {
|
||||
const open = last(blocks);
|
||||
if (open?.kind === "agent" && !open.complete) {
|
||||
blocks[blocks.length - 1] = { ...open, complete: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Folds a batch of log entries into the block list. Consecutive agent chunks
|
||||
// append to the open agent block, so streaming updates in place.
|
||||
export function foldEntries(
|
||||
previous: Block[],
|
||||
entries: StoredLogEntry[],
|
||||
localEchoes: Set<string>,
|
||||
): FoldResult {
|
||||
const blocks = [...previous];
|
||||
const result: FoldResult = {
|
||||
blocks,
|
||||
turnEnded: false,
|
||||
turnFailed: false,
|
||||
awaitingInput: false,
|
||||
externalUserMessages: 0,
|
||||
permissionRequests: [],
|
||||
resolvedRequestIds: [],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
const method = entry.notification?.method;
|
||||
if (!method) continue;
|
||||
|
||||
if (method === "_posthog/turn_complete") {
|
||||
if (!isIdleResumeTurnComplete(entry)) {
|
||||
result.turnEnded = true;
|
||||
closeOpenAgent(blocks);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (method === "_posthog/task_complete") {
|
||||
result.turnEnded = true;
|
||||
closeOpenAgent(blocks);
|
||||
continue;
|
||||
}
|
||||
if (method === "_posthog/awaiting_user_input") {
|
||||
result.turnEnded = true;
|
||||
result.awaitingInput = true;
|
||||
closeOpenAgent(blocks);
|
||||
continue;
|
||||
}
|
||||
if (method === "_posthog/error") {
|
||||
result.turnEnded = true;
|
||||
result.turnFailed = true;
|
||||
const params = entry.notification?.params as
|
||||
| { message?: string; error?: string }
|
||||
| undefined;
|
||||
result.errorMessage = params?.message ?? params?.error ?? "Agent error";
|
||||
continue;
|
||||
}
|
||||
if (method === "_posthog/permission_request") {
|
||||
const params = entry.notification?.params as
|
||||
| {
|
||||
requestId?: string;
|
||||
toolCallId?: string;
|
||||
toolCall?: {
|
||||
toolCallId?: string;
|
||||
title?: string;
|
||||
rawInput?: Record<string, unknown>;
|
||||
};
|
||||
options?: PermissionOption[];
|
||||
}
|
||||
| undefined;
|
||||
const toolCallId = params?.toolCallId ?? params?.toolCall?.toolCallId;
|
||||
if (params?.requestId && toolCallId) {
|
||||
result.permissionRequests.push({
|
||||
requestId: params.requestId,
|
||||
toolCallId,
|
||||
title: params.toolCall?.title ?? "Permission needed",
|
||||
input: params.toolCall?.rawInput,
|
||||
options: params.options ?? [],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (method === "_posthog/permission_resolved") {
|
||||
const params = entry.notification?.params as
|
||||
| { requestId?: string }
|
||||
| undefined;
|
||||
if (params?.requestId) result.resolvedRequestIds.push(params.requestId);
|
||||
continue;
|
||||
}
|
||||
if (method !== "session/update") continue;
|
||||
|
||||
const update = (entry.notification?.params as { update?: SessionUpdate })
|
||||
?.update;
|
||||
if (!update?.sessionUpdate) continue;
|
||||
|
||||
switch (update.sessionUpdate) {
|
||||
case "user_message_chunk": {
|
||||
const text = update.content?.text ?? "";
|
||||
if (!text) break;
|
||||
if (localEchoes.has(text)) {
|
||||
localEchoes.delete(text);
|
||||
break;
|
||||
}
|
||||
result.externalUserMessages += 1;
|
||||
closeOpenAgent(blocks);
|
||||
blocks.push({ kind: "user", id: nextId("user"), text });
|
||||
break;
|
||||
}
|
||||
case "agent_message_chunk": {
|
||||
const text = update.content?.text;
|
||||
if (!text) break;
|
||||
const open = last(blocks);
|
||||
if (open?.kind === "agent" && !open.complete) {
|
||||
blocks[blocks.length - 1] = { ...open, text: open.text + text };
|
||||
} else {
|
||||
blocks.push({
|
||||
kind: "agent",
|
||||
id: nextId("agent"),
|
||||
text,
|
||||
complete: false,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent_message": {
|
||||
const text = update.content?.text;
|
||||
if (!text) break;
|
||||
const open = last(blocks);
|
||||
if (open?.kind === "agent" && !open.complete) {
|
||||
blocks[blocks.length - 1] = { ...open, text, complete: true };
|
||||
} else {
|
||||
blocks.push({
|
||||
kind: "agent",
|
||||
id: nextId("agent"),
|
||||
text,
|
||||
complete: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent_thought_chunk": {
|
||||
const text = update.content?.text;
|
||||
if (!text) break;
|
||||
const open = last(blocks);
|
||||
if (open?.kind === "thought") {
|
||||
blocks[blocks.length - 1] = { ...open, text: open.text + text };
|
||||
} else {
|
||||
blocks.push({
|
||||
kind: "thought",
|
||||
id: nextId("thought"),
|
||||
text,
|
||||
at: entryTime(entry),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_call":
|
||||
case "tool_call_update": {
|
||||
const toolCallId = update.toolCallId;
|
||||
if (!toolCallId) break;
|
||||
const meta = update._meta?.claudeCode;
|
||||
const index = blocks.findIndex(
|
||||
(block) => block.kind === "tool" && block.id === toolCallId,
|
||||
);
|
||||
const existing =
|
||||
index >= 0 ? (blocks[index] as Block & { kind: "tool" }) : null;
|
||||
const merged: Block = {
|
||||
kind: "tool",
|
||||
id: toolCallId,
|
||||
title: update.title ?? existing?.title ?? meta?.toolName ?? "Tool",
|
||||
toolName: meta?.toolName ?? existing?.toolName,
|
||||
status: update.status
|
||||
? mapToolStatus(update.status)
|
||||
: (existing?.status ?? "pending"),
|
||||
input: update.rawInput ?? existing?.input,
|
||||
output:
|
||||
update.rawOutput !== undefined
|
||||
? update.rawOutput
|
||||
: existing?.output,
|
||||
parentId: meta?.parentToolCallId ?? existing?.parentId,
|
||||
at: existing?.at ?? entryTime(entry),
|
||||
updatedAt: entryTime(entry),
|
||||
};
|
||||
if (existing) {
|
||||
blocks[index] = merged;
|
||||
} else {
|
||||
// Close any streaming agent text so the next chunk starts a new bubble.
|
||||
const open = last(blocks);
|
||||
if (open?.kind === "agent" && !open.complete) {
|
||||
blocks[blocks.length - 1] = { ...open, complete: true };
|
||||
}
|
||||
blocks.push(merged);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "plan": {
|
||||
if (!Array.isArray(update.entries)) break;
|
||||
const index = blocks.findIndex((block) => block.kind === "plan");
|
||||
const plan: Block = {
|
||||
kind: "plan",
|
||||
id: "plan",
|
||||
entries: update.entries,
|
||||
};
|
||||
if (index >= 0) blocks[index] = plan;
|
||||
else blocks.push(plan);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||